1 /*
<lambda>null2  * Copyright 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.graphics.shapes.testcompose
18 
19 import androidx.compose.runtime.derivedStateOf
20 import androidx.compose.runtime.getValue
21 import androidx.compose.runtime.mutableFloatStateOf
22 import androidx.compose.runtime.mutableIntStateOf
23 import androidx.compose.runtime.mutableStateOf
24 import androidx.compose.runtime.setValue
25 import androidx.compose.ui.geometry.Offset
26 import androidx.compose.ui.graphics.Matrix
27 import androidx.graphics.shapes.CornerRounding
28 import androidx.graphics.shapes.Feature
29 import androidx.graphics.shapes.FeatureSerializer
30 import androidx.graphics.shapes.RoundedPolygon
31 import androidx.graphics.shapes.circle
32 import androidx.graphics.shapes.pill
33 import androidx.graphics.shapes.pillStar
34 import androidx.graphics.shapes.rectangle
35 import androidx.graphics.shapes.star
36 import kotlin.math.min
37 import kotlin.math.roundToInt
38 
39 data class ShapeItem(
40     val name: String,
41     var shapegen: () -> RoundedPolygon,
42     val shapeOutput: () -> String,
43     val usesSides: Boolean = true,
44     val usesWidthAndHeight: Boolean = false,
45     val usesPillStarFactor: Boolean = false,
46     val usesInnerRatio: Boolean = true,
47     val usesRoundness: Boolean = true,
48     val usesInnerParameters: Boolean = true
49 )
50 
51 open class ShapeParameters(
52     val name: String,
53     sides: Int = 5,
54     innerRadius: Float = 0.5f,
55     roundness: Float = 0f,
56     smooth: Float = 0f,
57     innerRoundness: Float = roundness,
58     innerSmooth: Float = smooth,
59     rotation: Float = 0f,
60     width: Float = 1f,
61     height: Float = 1f,
62     pillStarFactor: Float = .5f,
63     shapeId: ShapeId = ShapeId.Polygon,
64     splitProgress: Float = 1.0f,
65     customFeaturesOverlay: List<FeatureType> = listOf()
66 ) {
67     internal val sides = mutableFloatStateOf(sides.toFloat())
68     internal val innerRadius = mutableFloatStateOf(innerRadius)
69     internal val roundness = mutableFloatStateOf(roundness)
70     internal val smooth = mutableFloatStateOf(smooth)
71     internal val innerRoundness = mutableFloatStateOf(innerRoundness)
72     internal val innerSmooth = mutableFloatStateOf(innerSmooth)
73     internal val rotation = mutableFloatStateOf(rotation)
74     internal val width = mutableFloatStateOf(width)
75     internal val height = mutableFloatStateOf(height)
76     internal val pillStarFactor = mutableFloatStateOf(pillStarFactor)
77     internal val splitProgress = mutableFloatStateOf(splitProgress)
78     internal val customFeaturesOverlay = mutableStateOf(customFeaturesOverlay)
79 
80     internal var shapeIx by mutableIntStateOf(shapeId.ordinal)
81 
82     open val isCustom: Boolean = false
83 
84     // Primitive shapes we can draw (so far)
85     internal val shapes =
86         listOf(
87             ShapeItem(
88                 "Pill",
89                 shapegen = {
90                     RoundedPolygon.pill(
91                         width = this.width.floatValue,
92                         height = this.height.floatValue,
93                         smoothing = this.smooth.floatValue
94                     )
95                 },
96                 shapeOutput = {
97                     shapeDescription(
98                         id = "Pill",
99                         width = this.width.floatValue,
100                         height = this.height.floatValue,
101                         code =
102                             "RoundedPolygon.pill(width = $this.width.floatValue, " +
103                                 "height = $this.height.floatValue)"
104                     )
105                 },
106                 usesSides = false,
107                 usesInnerParameters = false,
108                 usesInnerRatio = false,
109                 usesRoundness = true,
110                 usesWidthAndHeight = true
111             ),
112             ShapeItem(
113                 "PillStar",
114                 shapegen = {
115                     RoundedPolygon.pillStar(
116                         width = this.width.floatValue,
117                         height = this.height.floatValue,
118                         numVerticesPerRadius = this.sides.floatValue.roundToInt(),
119                         innerRadiusRatio = this.innerRadius.floatValue,
120                         rounding =
121                             CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
122                         innerRounding =
123                             CornerRounding(
124                                 this.innerRoundness.floatValue,
125                                 this.innerSmooth.floatValue
126                             ),
127                         vertexSpacing = this.pillStarFactor.floatValue
128                     )
129                 },
130                 shapeOutput = {
131                     shapeDescription(
132                         id = "PillStar",
133                         width = this.width.floatValue,
134                         height = this.height.floatValue,
135                         numVerts = this.sides.floatValue.roundToInt(),
136                         innerRadius = this.innerRadius.floatValue,
137                         roundness = this.roundness.floatValue,
138                         smooth = this.smooth.floatValue,
139                         innerRoundness = this.innerRoundness.floatValue,
140                         innerSmooth = this.innerSmooth.floatValue,
141                         rotation = this.rotation.floatValue,
142                         code =
143                             "RoundedPolygon.pillStar(width = $width, height = $height," +
144                                 "numVerticesPerRadius = $sides, " +
145                                 "innerRadius = ${innerRadius}f, " +
146                                 "rounding = CornerRounding(${roundness}f, ${smooth}f), " +
147                                 "innerRounding = CornerRounding(${innerRoundness}f, ${innerSmooth}f))"
148                     )
149                 },
150                 usesWidthAndHeight = true,
151                 usesPillStarFactor = true
152             ),
153             ShapeItem(
154                 "Star",
155                 shapegen = {
156                     RoundedPolygon.star(
157                         radius = 1f,
158                         numVerticesPerRadius = this.sides.floatValue.roundToInt(),
159                         innerRadius = this.innerRadius.floatValue,
160                         rounding =
161                             CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
162                         innerRounding =
163                             CornerRounding(
164                                 this.innerRoundness.floatValue,
165                                 this.innerSmooth.floatValue
166                             )
167                     )
168                 },
169                 shapeOutput = {
170                     shapeDescription(
171                         id = "Star",
172                         sides = this.sides.floatValue.roundToInt(),
173                         innerRadius = this.innerRadius.floatValue,
174                         roundness = this.roundness.floatValue,
175                         smooth = this.smooth.floatValue,
176                         innerRoundness = this.innerRoundness.floatValue,
177                         innerSmooth = this.innerSmooth.floatValue,
178                         rotation = this.rotation.floatValue,
179                         code =
180                             "RoundedPolygon.star(" +
181                                 "radius = 2f, " +
182                                 "numVerticesPerRadius = ${this.sides.floatValue.roundToInt()}, " +
183                                 "innerRadius = ${this.innerRadius.floatValue}f, " +
184                                 "rounding = " +
185                                 "CornerRounding(${this.roundness.floatValue}f," +
186                                 "${this.smooth.floatValue}f), " +
187                                 "innerRounding = CornerRounding(${this.innerRoundness.floatValue}f, " +
188                                 "${this.innerSmooth.floatValue}f))"
189                     )
190                 },
191             ),
192             ShapeItem(
193                 "Polygon",
194                 shapegen = {
195                     RoundedPolygon(
196                         numVertices = this.sides.floatValue.roundToInt(),
197                         rounding =
198                             CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
199                     )
200                 },
201                 shapeOutput = {
202                     shapeDescription(
203                         id = "Polygon",
204                         sides = this.sides.floatValue.roundToInt(),
205                         roundness = this.roundness.floatValue,
206                         smooth = this.smooth.floatValue,
207                         rotation = this.rotation.floatValue,
208                         code =
209                             "RoundedPolygon(numVertices = ${this.sides.floatValue.roundToInt()}," +
210                                 "rounding = CornerRounding(${this.roundness.floatValue}f, " +
211                                 "${this.smooth.floatValue}f))"
212                     )
213                 },
214                 usesInnerRatio = false,
215                 usesInnerParameters = false
216             ),
217             ShapeItem(
218                 "Triangle",
219                 shapegen = {
220                     val points =
221                         floatArrayOf(
222                             radialToCartesian(1f, 270f.toRadians()).x,
223                             radialToCartesian(1f, 270f.toRadians()).y,
224                             radialToCartesian(1f, 30f.toRadians()).x,
225                             radialToCartesian(1f, 30f.toRadians()).y,
226                             radialToCartesian(this.innerRadius.floatValue, 90f.toRadians()).x,
227                             radialToCartesian(this.innerRadius.floatValue, 90f.toRadians()).y,
228                             radialToCartesian(1f, 150f.toRadians()).x,
229                             radialToCartesian(1f, 150f.toRadians()).y
230                         )
231                     RoundedPolygon(
232                         points,
233                         CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
234                         centerX = 0f,
235                         centerY = 0f
236                     )
237                 },
238                 shapeOutput = {
239                     shapeDescription(
240                         id = "Triangle",
241                         innerRadius = this.innerRadius.floatValue,
242                         smooth = this.smooth.floatValue,
243                         rotation = this.rotation.floatValue,
244                         code =
245                             "val points = floatArrayOf(" +
246                                 "    radialToCartesian(1f, 270f.toRadians()).x,\n" +
247                                 "    radialToCartesian(1f, 270f.toRadians()).y,\n" +
248                                 "    radialToCartesian(1f, 30f.toRadians()).x,\n" +
249                                 "    radialToCartesian(1f, 30f.toRadians()).y,\n" +
250                                 "    radialToCartesian(${this.innerRadius.floatValue}f, " +
251                                 "90f.toRadians()).x,\n" +
252                                 "    radialToCartesian(${this.innerRadius.floatValue}f, " +
253                                 "90f.toRadians()).y,\n" +
254                                 "    radialToCartesian(1f, 150f.toRadians()).x,\n" +
255                                 "    radialToCartesian(1f, 150f.toRadians()).y)\n" +
256                                 "RoundedPolygon(points, CornerRounding(" +
257                                 "${this.roundness.floatValue}f, ${this.smooth.floatValue}f), " +
258                                 "centerX = 0f, centerY = 0f)"
259                     )
260                 },
261                 usesSides = false,
262                 usesInnerParameters = false
263             ),
264             ShapeItem(
265                 "Blob",
266                 shapegen = {
267                     val sx = this.innerRadius.floatValue.coerceAtLeast(0.1f)
268                     val sy = this.roundness.floatValue.coerceAtLeast(0.1f)
269                     RoundedPolygon(
270                         vertices =
271                             floatArrayOf(
272                                 -sx,
273                                 -sy,
274                                 sx,
275                                 -sy,
276                                 sx,
277                                 sy,
278                                 -sx,
279                                 sy,
280                             ),
281                         rounding = CornerRounding(min(sx, sy), this.smooth.floatValue),
282                         centerX = 0f,
283                         centerY = 0f
284                     )
285                 },
286                 shapeOutput = {
287                     shapeDescription(
288                         id = "Blob",
289                         innerRadius = this.innerRadius.floatValue,
290                         roundness = this.roundness.floatValue,
291                         smooth = this.smooth.floatValue,
292                         rotation = this.rotation.floatValue,
293                         code =
294                             "val sx = ${this.innerRadius.floatValue}f.coerceAtLeast(0.1f)\n" +
295                                 "val sy = ${this.roundness.floatValue}f.coerceAtLeast(.1f)\n" +
296                                 "val verts = floatArrayOf(-sx, -sy, sx, -sy, sx, sy, -sx, sy)\n" +
297                                 "RoundedPolygon(verts, rounding = CornerRounding(min(sx, sy), " +
298                                 "${this.smooth.floatValue}f)," +
299                                 "centerX = 0f, centerY = 0f)"
300                     )
301                 },
302                 usesSides = false,
303                 usesInnerParameters = false
304             ),
305             ShapeItem(
306                 "CornerSE",
307                 shapegen = {
308                     RoundedPolygon(
309                         squarePoints(),
310                         perVertexRounding =
311                             listOf(
312                                 CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
313                                 CornerRounding(1f),
314                                 CornerRounding(1f),
315                                 CornerRounding(1f)
316                             ),
317                         centerX = 0f,
318                         centerY = 0f
319                     )
320                 },
321                 shapeOutput = {
322                     shapeDescription(
323                         id = "cornerSE",
324                         roundness = this.roundness.floatValue,
325                         smooth = this.smooth.floatValue,
326                         rotation = this.rotation.floatValue,
327                         code =
328                             "RoundedPolygon(floatArrayOf(1f, 1f, -1f, 1f, -1f, -1f, 1f, -1f), " +
329                                 "perVertexRounding = listOf(CornerRounding(" +
330                                 "${this.roundness.floatValue}f, ${this.smooth.floatValue}f), " +
331                                 "CornerRounding(1f), CornerRounding(1f),  CornerRounding(1f))," +
332                                 "centerX = 0f, centerY = 0f)"
333                     )
334                 },
335                 usesSides = false,
336                 usesInnerRatio = false,
337                 usesInnerParameters = false
338             ),
339             ShapeItem(
340                 "Circle",
341                 shapegen = {
342                     RoundedPolygon.circle(this.sides.floatValue.roundToInt())
343                         .transformed(
344                             Matrix().apply {
345                                 scale(
346                                     x = this@ShapeParameters.width.floatValue,
347                                     y = this@ShapeParameters.height.floatValue
348                                 )
349                                 rotateX(rotation)
350                             }
351                         )
352                 },
353                 shapeOutput = {
354                     shapeDescription(
355                         id = "Circle",
356                         roundness = this.roundness.floatValue,
357                         smooth = this.smooth.floatValue,
358                         rotation = this.rotation.floatValue,
359                         code = "RoundedPolygon.circle($sides)"
360                     )
361                 },
362                 usesSides = true,
363                 usesInnerRatio = false,
364                 usesWidthAndHeight = true,
365                 usesInnerParameters = false
366             ),
367             ShapeItem(
368                 "Rectangle",
369                 shapegen = {
370                     RoundedPolygon.rectangle(
371                         width = 4f,
372                         height = 2f,
373                         rounding =
374                             CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
375                     )
376                 },
377                 shapeOutput = {
378                     shapeDescription(
379                         id = "Rectangle",
380                         numVerts = 4,
381                         roundness = this.roundness.floatValue,
382                         smooth = this.smooth.floatValue,
383                         rotation = this.rotation.floatValue,
384                         code =
385                             "RoundedPolygon.rectangle(width = 4f, height = 2f, " +
386                                 "rounding = CornerRounding(" +
387                                 "${this.roundness.floatValue}f, ${this.smooth.floatValue}f))"
388                     )
389                 },
390                 usesSides = false,
391                 usesInnerRatio = false,
392                 usesInnerParameters = false
393             )
394 
395             /*
396             TODO: Add quarty. Needs to be able to specify a rounding radius of up to 2f
397             ShapeItem("Quarty", { DefaultShapes.quarty(roundness.value, smooth.value) },
398             usesSides = false, usesInnerRatio = false),
399             */
400         )
401 
402     open fun copy() =
403         ShapeParameters(
404             this.name,
405             this.sides.floatValue.roundToInt(),
406             this.innerRadius.floatValue,
407             this.roundness.floatValue,
408             this.smooth.floatValue,
409             this.innerRoundness.floatValue,
410             this.innerSmooth.floatValue,
411             this.rotation.floatValue,
412             this.width.floatValue,
413             this.height.floatValue,
414             this.pillStarFactor.floatValue,
415             ShapeId.values()[this.shapeIx],
416             this.splitProgress.floatValue,
417             this.customFeaturesOverlay.value.map { it }
418         )
419 
420     enum class ShapeId {
421         Pill,
422         PillStar,
423         Star,
424         Polygon,
425         Triangle,
426         Blob,
427         CornerSE,
428         Circle,
429         Rectangle
430     }
431 
432     fun serialized(): String =
433         """
434             val features: List<Feature> = FeatureSerializer.parse("${FeatureSerializer.serialize(genShape().features)}")
435             val shape: RoundedPolygon = RoundedPolygon(features, centerX = <recommended to add>, centerY = <recommended to add>)
436         """
437             .trimIndent()
438 
439     private fun shapeDescription(
440         id: String? = null,
441         numVerts: Int? = null,
442         sides: Int? = null,
443         innerRadius: Float? = null,
444         roundness: Float? = null,
445         innerRoundness: Float? = null,
446         smooth: Float? = null,
447         innerSmooth: Float? = null,
448         rotation: Float? = null,
449         width: Float? = null,
450         height: Float? = null,
451         pillStarFactor: Float? = null,
452         splitProgress: Float? = null,
453         customFeaturesOverlay: List<FeatureType>? = null,
454         code: String? = null,
455     ): String {
456         var description = "ShapeParameters:\n"
457         if (id != null) description += "shapeId = $id, "
458         if (numVerts != null) description += "numVertices = $numVerts, "
459         if (sides != null) description += "sides = $sides, "
460         if (innerRadius != null) description += "innerRadius = $innerRadius, "
461         if (roundness != null) description += "roundness = $roundness, "
462         if (innerRoundness != null) description += "innerRoundness = $innerRoundness, "
463         if (smooth != null) description += "smoothness = $smooth, "
464         if (innerSmooth != null) description += "innerSmooth = $innerSmooth, "
465         if (rotation != null) description += "rotation = $rotation, "
466         if (width != null) description += "width = $width, "
467         if (height != null) description += "height = $height, "
468         if (pillStarFactor != null) description += "pillStarFactor = $pillStarFactor, "
469         if (splitProgress != null) description += "splitProgress = $splitProgress, "
470         if (customFeaturesOverlay != null) {
471             description +=
472                 "customFeaturesOverlay = ${customFeaturesOverlay.joinToString(separator = " ") { it.name }}, "
473         }
474         if (code != null) {
475             description += "\nCode:\n$code"
476         }
477         return description
478     }
479 
480     open fun selectedShape() = derivedStateOf { shapes[shapeIx] }
481 
482     fun genShape(autoSize: Boolean = true): RoundedPolygon {
483         // TODO: b/378433883 - Improve combined overlay - split interactions
484 
485         val original = selectedShape().value.shapegen()
486         val split = original.split(splitProgress.floatValue)
487 
488         if (split.features.size != customFeaturesOverlay.value.size) {
489             customFeaturesOverlay.value = split.features.map { it.toFeatureType() }
490         }
491 
492         val customizedSplitPolygon =
493             RoundedPolygon(
494                 split.features.mapIndexed { index, feature ->
495                     customFeaturesOverlay.value[index].apply(feature)
496                 }
497             )
498 
499         return customizedSplitPolygon.transformed(
500             Matrix().apply {
501                 if (autoSize) {
502                     val bounds = customizedSplitPolygon.getBounds()
503                     // Move the center to the origin.
504                     translate(
505                         x = -(bounds.left + bounds.right) / 2,
506                         y = -(bounds.top + bounds.bottom) / 2
507                     )
508 
509                     // Scale to the [-1, 1] range
510                     val scale = 2f / bounds.maxDimension
511                     scale(x = scale, y = scale)
512                 }
513                 // Apply the needed rotation
514                 rotateZ(rotation.floatValue)
515             }
516         )
517     }
518 
519     internal fun equals(other: ShapeParameters) =
520         this.shapeDescription(
521             sides = sides.floatValue.toInt(),
522             innerRadius = innerRadius.floatValue,
523             roundness = roundness.floatValue,
524             smooth = smooth.floatValue,
525             innerRoundness = innerRoundness.floatValue,
526             innerSmooth = innerSmooth.floatValue,
527             rotation = rotation.floatValue,
528             width = width.floatValue,
529             height = height.floatValue,
530             pillStarFactor = pillStarFactor.floatValue,
531             splitProgress = splitProgress.floatValue,
532             customFeaturesOverlay = customFeaturesOverlay.value
533         ) ==
534             other.shapeDescription(
535                 sides = other.sides.floatValue.toInt(),
536                 innerRadius = other.innerRadius.floatValue,
537                 roundness = other.roundness.floatValue,
538                 smooth = other.smooth.floatValue,
539                 innerRoundness = other.innerRoundness.floatValue,
540                 innerSmooth = other.innerSmooth.floatValue,
541                 rotation = other.rotation.floatValue,
542                 width = other.width.floatValue,
543                 height = other.height.floatValue,
544                 pillStarFactor = other.pillStarFactor.floatValue,
545                 splitProgress = other.splitProgress.floatValue,
546                 customFeaturesOverlay = other.customFeaturesOverlay.value
547             )
548 
549     private fun radialToCartesian(
550         radius: Float,
551         angleRadians: Float,
552         center: Offset = Offset.Zero
553     ) = directionVector(angleRadians) * radius + center
554 }
555 
556 class CustomShapeParameters(
557     name: String,
558     sides: Int = 5,
559     innerRadius: Float = 0.5f,
560     roundness: Float = 0f,
561     smooth: Float = 0f,
562     innerRoundness: Float = roundness,
563     innerSmooth: Float = smooth,
564     rotation: Float = 0f,
565     width: Float = 1f,
566     height: Float = 1f,
567     pillStarFactor: Float = .5f,
568     shapeId: ShapeId = ShapeId.Polygon,
569     splitProgress: Float = 1.0f,
570     customFeaturesOverlay: List<FeatureType> = listOf(),
571     private val shapegen: () -> RoundedPolygon
572 ) :
573     ShapeParameters(
574         name,
575         sides,
576         innerRadius,
577         roundness,
578         smooth,
579         innerRoundness,
580         innerSmooth,
581         rotation,
582         width,
583         height,
584         pillStarFactor,
585         shapeId,
586         splitProgress,
587         customFeaturesOverlay
588     ) {
589 
590     override val isCustom: Boolean = true
591 
<lambda>null592     override fun selectedShape() = derivedStateOf {
593         ShapeItem(name = name, shapegen = shapegen, shapeOutput = { "Custom Shape: $name" })
594     }
595 
copynull596     override fun copy(): CustomShapeParameters =
597         CustomShapeParameters(
598             this.name,
599             this.sides.floatValue.roundToInt(),
600             this.innerRadius.floatValue,
601             this.roundness.floatValue,
602             this.smooth.floatValue,
603             this.innerRoundness.floatValue,
604             this.innerSmooth.floatValue,
605             this.rotation.floatValue,
606             this.width.floatValue,
607             this.height.floatValue,
608             this.pillStarFactor.floatValue,
609             ShapeId.values()[this.shapeIx],
610             this.splitProgress.floatValue,
611             this.customFeaturesOverlay.value.map { it },
612             this.shapegen,
613         )
614 }
615 
squarePointsnull616 private fun squarePoints() = floatArrayOf(1f, 1f, -1f, 1f, -1f, -1f, 1f, -1f)
617 
618 enum class FeatureType {
619     IGNORABLE {
620         override fun apply(feature: Feature) = Feature.buildIgnorableFeature(feature.cubics)
621     },
622     CONVEX_CORNER {
623         override fun apply(feature: Feature) = Feature.buildConvexCorner(feature.cubics)
624     },
625     CONCAVE_CORNER {
626         override fun apply(feature: Feature) = Feature.buildConcaveCorner(feature.cubics)
627     };
628 
629     abstract fun apply(feature: Feature): Feature
630 }
631 
toFeatureTypenull632 internal fun Feature.toFeatureType(): FeatureType =
633     if (isEdge) {
634         FeatureType.IGNORABLE
635     } else if (isConvexCorner) {
636         FeatureType.CONVEX_CORNER
637     } else if (isConcaveCorner) {
638         FeatureType.CONCAVE_CORNER
639     } else {
640         FeatureType.IGNORABLE
641     }
642 
toggleFeatureTypenull643 internal fun toggleFeatureType(typeToToggle: FeatureType): FeatureType {
644     val currentFeatureIndex = FeatureType.values().indexOf(typeToToggle)
645     val nextFeatureType =
646         FeatureType.values()[(currentFeatureIndex + 1) % FeatureType.values().size]
647     return nextFeatureType
648 }
649