Skip to content

Commit 997187a

Browse files
committed
Bezier geometry: convert to lines using De Castelju when first or last pairs of control points are too close. Includes tests.
Fixes #852
1 parent f5a4696 commit 997187a

4 files changed

Lines changed: 194 additions & 38 deletions

File tree

src/Bloc-Alexandrie-Tests/BlSpaceFixture.class.st

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,6 +1872,28 @@ BlSpaceFixture >> buildVariantsOfBezier4CPs [
18721872
(BlBezierCurveGeometry controlPoints: { 5@0. 15@30. 25@10. 35@40 })
18731873
]
18741874

1875+
{ #category : #fixtures }
1876+
BlSpaceFixture >> buildVariantsOfBezier4CPsWithCloseFirstAndSecond [
1877+
"Regression test for the stroke-outline self-intersection fix.
1878+
The first two control points (P0 and P1) are separated by only 1 unit,
1879+
much less than the stroke half-width (5), so the De Casteljau safe path
1880+
is activated at the start of the curve."
1881+
1882+
self buildVariantsOfLinelikeGeometry:
1883+
(BlBezierCurveGeometry controlPoints: { 5@0. 5@1. 25@10. 35@40 })
1884+
]
1885+
1886+
{ #category : #fixtures }
1887+
BlSpaceFixture >> buildVariantsOfBezier4CPsWithCloseThirdAndFourth [
1888+
"Regression test for the stroke-outline self-intersection fix.
1889+
The last two control points (P2 and P3) are separated by only 1 unit,
1890+
much less than the stroke half-width (5), so the De Casteljau safe path
1891+
is activated at the end of the curve."
1892+
1893+
self buildVariantsOfLinelikeGeometry:
1894+
(BlBezierCurveGeometry controlPoints: { 5@0. 15@30. 35@39. 35@40 })
1895+
]
1896+
18751897
{ #category : #fixtures }
18761898
BlSpaceFixture >> buildVariantsOfCircleSector [
18771899

src/Bloc-Alexandrie/BlBezierCurveGeometry.extension.st

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,58 +4,65 @@ Extension { #name : #BlBezierCurveGeometry }
44
BlBezierCurveGeometry >> aeApplyTo: aeCanvas element: aBlElement [
55

66
aeCanvas pathFactory: [ :cairoContext |
7-
self aeDrawAccordingToControlPointsOn: cairoContext ]
8-
7+
self aeDrawAccordingToControlPointsOn: cairoContext strokeWidth: aBlElement border width ]
98
]
109

1110
{ #category : #'*Bloc-Alexandrie' }
12-
BlBezierCurveGeometry >> aeDrawAccordingToControlPointsOn: cairoContext [
13-
"Based on RSAthensRenderer>>#buildBezierPath:"
11+
BlBezierCurveGeometry >> aeDrawAccordingToControlPointsOn: cairoContext strokeWidth: strokeWidth [
12+
"Based on RSAthensRenderer>>#buildBezierPath:
13+
14+
For the cubic (4-point) case we choose between two rendering strategies:
15+
16+
REGULAR PATH — cairo_curve_to (cairo_curve_to).
17+
Cairo's native bezier stroking is fast and correct when the local
18+
radius of curvature at both endpoints is larger than the stroke
19+
half-width.
1420
15-
controlPoints size = 2 ifTrue: [
21+
SAFE PATH — De Casteljau polyline (flattenedCubicVerticesDo:).
22+
When one of the end control segments (P0→P1 or P2→P3) is shorter
23+
than halfWidth, the radius of curvature at that endpoint is smaller
24+
than the stroke radius. Cairo then builds a self-intersecting stroke
25+
outline polygon, which it fills incorrectly — producing a visible blob
26+
or fin artifact. Emitting explicit lineTo: calls instead makes Cairo
27+
use its polyline stroking code, which handles inner corners with a
28+
concave vertex join and never self-intersects."
29+
30+
controlPoints size = 2 ifTrue: [
1631
cairoContext
1732
lineFrom: controlPoints first
1833
to: controlPoints second.
1934
^ self ].
2035

21-
controlPoints size = 3 ifTrue: [
36+
controlPoints size = 3 ifTrue: [
2237
cairoContext
2338
moveTo: controlPoints first;
2439
quadraticCurveFromCurrentPoint: controlPoints first
2540
via: controlPoints second
2641
to: controlPoints third.
2742
^ self ].
2843

29-
controlPoints size = 4 ifTrue: [
30-
cairoContext
31-
moveTo: controlPoints first;
32-
curveVia: controlPoints second
33-
via: controlPoints third
34-
to: controlPoints fourth.
44+
controlPoints size = 4 ifTrue: [
45+
| p0 p1 p2 p3 halfWidth |
46+
p0 := controlPoints first.
47+
p1 := controlPoints second.
48+
p2 := controlPoints third.
49+
p3 := controlPoints fourth.
50+
halfWidth := strokeWidth / 2.0.
51+
52+
((p1 distanceTo: p0) < halfWidth
53+
or: [ (p2 distanceTo: p3) < halfWidth ])
54+
ifTrue: [
55+
"Safe path: De Casteljau flattening avoids self-intersecting stroke outline."
56+
self flattenedCubicVerticesDo: [ :x :y |
57+
cairoContext lineToX: x y: y ] ]
58+
ifFalse: [
59+
"Regular path: Cairo's native bezier stroke is correct here."
60+
cairoContext
61+
moveTo: p0;
62+
curveVia: p1
63+
via: p2
64+
to: p3 ].
3565
^ self ].
3666

3767
self notYetImplemented
3868
]
39-
40-
{ #category : #'*Bloc-Alexandrie' }
41-
BlBezierCurveGeometry >> asPolylineVertices [
42-
43-
| c b a via1 via2 start end |
44-
controlPoints size = 2 ifTrue: [
45-
^ Array
46-
with: controlPoints first
47-
with: controlPoints second ].
48-
49-
start := controlPoints first.
50-
via1 := controlPoints second.
51-
via2 := controlPoints third.
52-
end := controlPoints size = 3
53-
ifTrue: [ via2 ]
54-
ifFalse: [ controlPoints fourth ].
55-
c := 3 * (via1 - start).
56-
b := 3 * (via2 - via1) - c.
57-
a := end - start - c - b.
58-
59-
^ (0.0 to: 1.0 by: 0.2) collect: [ :t |
60-
a * (t raisedTo: 3) + (b * (t * t)) + (c * t) + start ]
61-
]

src/Bloc/BlBezierCurveGeometry.class.st

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,43 @@ BlBezierCurveGeometry >> = anObject [
2525
^ self controlPoints = anObject controlPoints
2626
]
2727

28+
{ #category : #geometry }
29+
BlBezierCurveGeometry >> asPolylineVertices [
30+
"Answer an Array of Points approximating the curve, for hit-testing.
31+
32+
For the cubic (4-point) case this reuses the same De Casteljau-flattened
33+
cache that the renderer uses, so both share a single computation and a
34+
single allocation.
35+
36+
For the quadratic (3-point) case a coarse uniform sample is sufficient
37+
for hit-testing purposes."
38+
39+
| c b a via1 via2 start end |
40+
41+
controlPoints size = 2 ifTrue: [
42+
^ Array
43+
with: controlPoints first
44+
with: controlPoints second ].
45+
46+
controlPoints size = 4 ifTrue: [
47+
| result |
48+
result := OrderedCollection new.
49+
self flattenedCubicVerticesDo: [ :x :y | result add: x @ y ].
50+
^ result asArray ].
51+
52+
"size = 3: treat as degenerate cubic (CP2 = CP3) and sample uniformly."
53+
start := controlPoints first.
54+
via1 := controlPoints second.
55+
via2 := controlPoints third.
56+
end := via2.
57+
c := 3 * (via1 - start).
58+
b := 3 * (via2 - via1) - c.
59+
a := end - start - c - b.
60+
61+
^ #(0.0 0.2 0.4 0.6 0.8 1.0) collect: [ :t |
62+
a * (t raisedTo: 3) + (b * (t * t)) + (c * t) + start ]
63+
]
64+
2865
{ #category : #'geometry testing' }
2966
BlBezierCurveGeometry >> containsPoint: aPoint alreadyInGeometryBoundsOf: aBlElement [
3067
"Answer whether aPoint is contained by this geometry.
@@ -33,10 +70,10 @@ BlBezierCurveGeometry >> containsPoint: aPoint alreadyInGeometryBoundsOf: aBlEle
3370

3471
| lines ir |
3572
ir := aBlElement border width / 2.
36-
lines := self asPolylineVertices. "TODO: cache"
73+
lines := self asPolylineVertices.
3774

3875
^ (1 to: lines size - 1) anySatisfy: [ :i |
39-
aPoint
76+
aPoint
4077
onLineFrom: (lines at: i)
4178
to: (lines at: i + 1)
4279
within: ir ]
@@ -57,6 +94,96 @@ BlBezierCurveGeometry >> controlPoints: aCollection [
5794
self releaseCache
5895
]
5996

97+
{ #category : #private }
98+
BlBezierCurveGeometry >> flattenCubicFrom: p0 cp1: p1 cp2: p2 to: p3 into: aCollection depth: depth [
99+
"Private helper for flattenedCubicVerticesDo:.
100+
Recursively subdivides the cubic Bezier p0-p1-p2-p3 using De Casteljau and
101+
appends each new vertex to aCollection (p0 is assumed to be already present).
102+
103+
Flatness criterion: the perpendicular distance from each interior control
104+
point to the chord p0→p3 must be ≤ 1.0 user-space unit (≈ 1 pixel).
105+
Working with squared distances avoids a sqrt per call.
106+
107+
A depth limit of 16 prevents unbounded recursion on degenerate inputs
108+
(e.g. all four points coincident)."
109+
110+
| chordX chordY chordLenSq d1Sq d2Sq q0 q1 q2 r0 r1 mid |
111+
112+
depth > 16 ifTrue: [
113+
aCollection add: p3.
114+
^ self ].
115+
116+
"Chord vector p0→p3."
117+
chordX := p3 x - p0 x.
118+
chordY := p3 y - p0 y.
119+
chordLenSq := (chordX * chordX) + (chordY * chordY).
120+
121+
"Negligibly short chord → the whole sub-curve is sub-pixel; close it."
122+
chordLenSq < 1.0e-8 ifTrue: [
123+
aCollection add: p3.
124+
^ self ].
125+
126+
"Perpendicular-distance² from each interior control point to the chord.
127+
d = |cross(chord, p - p0)| / |chord| → d² = cross² / chordLenSq"
128+
d1Sq := ((p1 x - p0 x) * chordY - ((p1 y - p0 y) * chordX)) squared
129+
/ chordLenSq.
130+
d2Sq := ((p2 x - p0 x) * chordY - ((p2 y - p0 y) * chordX)) squared
131+
/ chordLenSq.
132+
133+
"Flat enough? tolerance = 1.0 px → tolerance² = 1.0"
134+
(d1Sq max: d2Sq) <= 1.0 ifTrue: [
135+
aCollection add: p3.
136+
^ self ].
137+
138+
"Subdivide at t = 0.5 using De Casteljau."
139+
q0 := (p0 + p1) * 0.5.
140+
q1 := (p1 + p2) * 0.5.
141+
q2 := (p2 + p3) * 0.5.
142+
r0 := (q0 + q1) * 0.5.
143+
r1 := (q1 + q2) * 0.5.
144+
mid := (r0 + r1) * 0.5.
145+
146+
self
147+
flattenCubicFrom: p0 cp1: q0 cp2: r0 to: mid
148+
into: aCollection
149+
depth: depth + 1.
150+
self
151+
flattenCubicFrom: mid cp1: r1 cp2: q2 to: p3
152+
into: aCollection
153+
depth: depth + 1
154+
]
155+
156+
{ #category : #geometry }
157+
BlBezierCurveGeometry >> flattenedCubicVerticesDo: aBlock [
158+
"Evaluate aBlock with (x, y) for each vertex of the De Casteljau-flattened
159+
cubic Bezier path, including the start point p0.
160+
161+
The flattened vertex array is computed lazily on first call and stored in
162+
pathCache geometryPath. The cache is cleared automatically whenever
163+
releaseCache is called (e.g. from controlPoints:), which also clears the
164+
inherited strokedBounds cache.
165+
166+
Usage:
167+
self flattenedCubicVerticesDo: [:x :y | canvas lineToX: x y: y]"
168+
169+
| vertices |
170+
vertices := pathCache geometryPath ifNil: [
171+
| result |
172+
result := OrderedCollection with: controlPoints first.
173+
self
174+
flattenCubicFrom: controlPoints first
175+
cp1: controlPoints second
176+
cp2: controlPoints third
177+
to: controlPoints fourth
178+
into: result
179+
depth: 0.
180+
result := result asArray.
181+
pathCache geometryPath: result.
182+
result ].
183+
184+
vertices do: [ :each | aBlock value: each x value: each y ]
185+
]
186+
60187
{ #category : #'geometry bounds' }
61188
BlBezierCurveGeometry >> geometryBounds: aBlBounds [
62189

src/Bloc/BlPathCache.class.st

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ BlPathCache >> geometryPath [
5454
{ #category : #accessing }
5555
BlPathCache >> geometryPath: anObject [
5656

57-
geometryPath := nil
57+
geometryPath := anObject
5858
]
5959

6060
{ #category : #'session management' }

0 commit comments

Comments
 (0)