P1 Stream C: organic design primitives (BlobShape, RadialGlow, HoneycombOverlay)
Ports iOS OrganicDesign.swift primitives to Compose Multiplatform: - BlobShape: seeded deterministic irregular shape - RadialGlow: radial-gradient backdrop composable - HoneycombOverlay: tiled hex pattern modifier - OrganicRadius constants matching iOS Determinism guaranteed by parametrized tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,439 @@
|
|||||||
|
package com.tt.honeyDue.ui.design
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Organic Design Primitives (ported from iOS OrganicDesign.swift)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// This module owns the minimal set of organic primitives used across screens:
|
||||||
|
// • BlobShape — seeded, deterministic irregular Compose Shape
|
||||||
|
// • RadialGlow — radial-gradient backdrop composable + value params
|
||||||
|
// • HoneycombOverlay — tiled hexagonal texture modifier / composable
|
||||||
|
// • OrganicCard — card with blob backdrop + soft shadow
|
||||||
|
// • OrganicRadius — organic corner-radius tokens
|
||||||
|
// • OrganicSpacing — organic spacing scale matching iOS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BlobShape
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Irregular blob shape with [VARIATION_COUNT] canonical variations matching
|
||||||
|
* iOS `OrganicBlobShape` (cloud / pebble / leaf). Accepts an optional [seed]
|
||||||
|
* that perturbs the control points deterministically — the same seed always
|
||||||
|
* produces the same geometry.
|
||||||
|
*
|
||||||
|
* For testability, call [serializePath] to obtain a canonical string form of
|
||||||
|
* the emitted path operations without rendering.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class BlobShape(
|
||||||
|
private val variation: Int = 0,
|
||||||
|
private val seed: Long = 0L,
|
||||||
|
) : Shape {
|
||||||
|
|
||||||
|
override fun createOutline(
|
||||||
|
size: Size,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
): Outline {
|
||||||
|
val path = Path()
|
||||||
|
applyOps(path, buildOps(size, variation, seed))
|
||||||
|
return Outline.Generic(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Number of distinct named variations — matches iOS `variation % 3`. */
|
||||||
|
const val VARIATION_COUNT: Int = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical string serialization of the path operations for a given
|
||||||
|
* [size], [variation] and [seed]. Used by tests (and debugging) to
|
||||||
|
* assert deterministic output without rendering.
|
||||||
|
*/
|
||||||
|
fun serializePath(size: Size, variation: Int, seed: Long): String {
|
||||||
|
val ops = buildOps(size, variation, seed)
|
||||||
|
return buildString {
|
||||||
|
for (op in ops) {
|
||||||
|
append(op.toSerialized())
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internals ---------------------------------------------------
|
||||||
|
|
||||||
|
private fun buildOps(size: Size, variation: Int, seed: Long): List<PathOp> {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val rng = Lcg(seed)
|
||||||
|
// Jitter magnitude capped at 2% of the min dimension — keeps the
|
||||||
|
// silhouette readable while still perturbing for a given seed.
|
||||||
|
val jitterMax = 0.02f * minOf(w, h)
|
||||||
|
fun jx(v: Float): Float = if (seed == 0L) v else v + rng.nextFloat(-jitterMax, jitterMax)
|
||||||
|
fun jy(v: Float): Float = if (seed == 0L) v else v + rng.nextFloat(-jitterMax, jitterMax)
|
||||||
|
|
||||||
|
fun pt(fx: Float, fy: Float) = Offset(jx(fx * w), jy(fy * h))
|
||||||
|
|
||||||
|
val ops = mutableListOf<PathOp>()
|
||||||
|
when (floorModInt(variation, VARIATION_COUNT)) {
|
||||||
|
0 -> {
|
||||||
|
// Soft cloud-like blob (iOS variation 0).
|
||||||
|
ops += PathOp.MoveTo(pt(0.10f, 0.50f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.00f, 0.10f), pt(0.25f, 0.00f), pt(0.50f, 0.05f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.75f, 0.10f), pt(1.00f, 0.25f), pt(0.95f, 0.45f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.90f, 0.70f), pt(0.80f, 0.95f), pt(0.55f, 0.95f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.25f, 0.95f), pt(0.05f, 0.75f), pt(0.10f, 0.50f))
|
||||||
|
}
|
||||||
|
1 -> {
|
||||||
|
// Pebble shape (iOS variation 1).
|
||||||
|
ops += PathOp.MoveTo(pt(0.15f, 0.40f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.10f, 0.15f), pt(0.35f, 0.05f), pt(0.60f, 0.08f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.85f, 0.12f), pt(0.95f, 0.35f), pt(0.90f, 0.55f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.85f, 0.80f), pt(0.65f, 0.95f), pt(0.45f, 0.92f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.20f, 0.88f), pt(0.08f, 0.65f), pt(0.15f, 0.40f))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Leaf-like shape (iOS variation 2+).
|
||||||
|
ops += PathOp.MoveTo(pt(0.05f, 0.50f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.05f, 0.20f), pt(0.25f, 0.02f), pt(0.50f, 0.02f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.75f, 0.02f), pt(0.95f, 0.20f), pt(0.95f, 0.50f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.95f, 0.80f), pt(0.75f, 0.98f), pt(0.50f, 0.98f))
|
||||||
|
ops += PathOp.CubicTo(pt(0.25f, 0.98f), pt(0.05f, 0.80f), pt(0.05f, 0.50f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ops += PathOp.Close
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyOps(path: Path, ops: List<PathOp>) {
|
||||||
|
for (op in ops) {
|
||||||
|
when (op) {
|
||||||
|
is PathOp.MoveTo -> path.moveTo(op.p.x, op.p.y)
|
||||||
|
is PathOp.LineTo -> path.lineTo(op.p.x, op.p.y)
|
||||||
|
is PathOp.CubicTo -> path.cubicTo(
|
||||||
|
op.c1.x, op.c1.y,
|
||||||
|
op.c2.x, op.c2.y,
|
||||||
|
op.end.x, op.end.y,
|
||||||
|
)
|
||||||
|
PathOp.Close -> path.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Canonical op model -------------------------------------------------
|
||||||
|
|
||||||
|
private sealed class PathOp {
|
||||||
|
data class MoveTo(val p: Offset) : PathOp()
|
||||||
|
data class LineTo(val p: Offset) : PathOp()
|
||||||
|
data class CubicTo(val c1: Offset, val c2: Offset, val end: Offset) : PathOp()
|
||||||
|
object Close : PathOp()
|
||||||
|
|
||||||
|
fun toSerialized(): String = when (this) {
|
||||||
|
is MoveTo -> "M ${fmt(p.x)},${fmt(p.y)}"
|
||||||
|
is LineTo -> "L ${fmt(p.x)},${fmt(p.y)}"
|
||||||
|
is CubicTo -> "C ${fmt(c1.x)},${fmt(c1.y)} ${fmt(c2.x)},${fmt(c2.y)} ${fmt(end.x)},${fmt(end.y)}"
|
||||||
|
Close -> "Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fmt(v: Float): String {
|
||||||
|
// Canonicalise via rounded fixed precision (4 decimal places) so two runs
|
||||||
|
// of the same LCG serialise bit-for-bit identically.
|
||||||
|
val scaled = kotlin.math.round(v * 10_000f) / 10_000f
|
||||||
|
return scaled.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Floor-mod for positive divisor — commonMain has no `Math.floorMod`. */
|
||||||
|
private fun floorModInt(x: Int, m: Int): Int {
|
||||||
|
val r = x % m
|
||||||
|
return if (r < 0) r + m else r
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal linear-congruential PRNG. Used for deterministic perturbation of
|
||||||
|
* [BlobShape] control points. Chosen over `kotlin.random.Random(seed)` so the
|
||||||
|
* sequence is identical across Kotlin targets (JVM / native / JS / Wasm).
|
||||||
|
*
|
||||||
|
* Constants are the Numerical Recipes parameters:
|
||||||
|
* a = 1664525, c = 1013904223, m = 2^32
|
||||||
|
*/
|
||||||
|
private class Lcg(seed: Long) {
|
||||||
|
private var state: Long = seed xor 0x5DEECE66DL and 0xFFFFFFFFL
|
||||||
|
|
||||||
|
fun nextFloat(min: Float, max: Float): Float {
|
||||||
|
state = (1664525L * state + 1013904223L) and 0xFFFFFFFFL
|
||||||
|
val unit = state.toFloat() / 4_294_967_296f
|
||||||
|
return min + unit * (max - min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RadialGlow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object describing a radial glow. Exposed separately so call-sites and
|
||||||
|
* tests can reason about the parameters without touching the composable.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class RadialGlowParams(
|
||||||
|
val color: Color,
|
||||||
|
val center: Offset,
|
||||||
|
val radius: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a radial-gradient glow behind [content]. The gradient fades from
|
||||||
|
* [color] at [center] to transparent at [radius].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RadialGlow(
|
||||||
|
color: Color,
|
||||||
|
center: Offset,
|
||||||
|
radius: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable BoxScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
val glowModifier = modifier.drawBehind {
|
||||||
|
drawRect(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(color, Color.Transparent),
|
||||||
|
center = center,
|
||||||
|
radius = radius.coerceAtLeast(1f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(modifier = glowModifier, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HoneycombOverlay
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Honeycomb overlay configuration — mirrors iOS `HoneycombTextureCache`
|
||||||
|
* constants so the two platforms render the same tile.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class HoneycombOverlayConfig(
|
||||||
|
val tileWidth: Float = 60f,
|
||||||
|
val tileHeight: Float = 103.92f,
|
||||||
|
val strokeColor: Color = Color(0xFFC4856A),
|
||||||
|
val strokeWidth: Float = 0.8f,
|
||||||
|
val opacity: Float = 0.10f,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a tiled honeycomb (hexagonal lattice) pattern as a background layer.
|
||||||
|
* Drawn procedurally; Stream B may later swap this for a cached bitmap.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HoneycombOverlay(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
config: HoneycombOverlayConfig = HoneycombOverlayConfig(),
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier.fillMaxSize()) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val tW = config.tileWidth
|
||||||
|
val tH = config.tileHeight
|
||||||
|
val stroke = Stroke(width = config.strokeWidth)
|
||||||
|
val color = config.strokeColor.copy(
|
||||||
|
alpha = config.strokeColor.alpha * config.opacity
|
||||||
|
)
|
||||||
|
var y = 0f
|
||||||
|
while (y < h) {
|
||||||
|
var x = 0f
|
||||||
|
while (x < w) {
|
||||||
|
drawHexTile(x, y, tW, tH, color, stroke)
|
||||||
|
x += tW
|
||||||
|
}
|
||||||
|
y += tH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifier form of [HoneycombOverlay] — paints the hex lattice behind the
|
||||||
|
* laid-out content of the composable it decorates.
|
||||||
|
*/
|
||||||
|
fun Modifier.honeycombOverlay(
|
||||||
|
config: HoneycombOverlayConfig = HoneycombOverlayConfig(),
|
||||||
|
): Modifier = this.drawBehind {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val tW = config.tileWidth
|
||||||
|
val tH = config.tileHeight
|
||||||
|
val stroke = Stroke(width = config.strokeWidth)
|
||||||
|
val color = config.strokeColor.copy(
|
||||||
|
alpha = config.strokeColor.alpha * config.opacity
|
||||||
|
)
|
||||||
|
var y = 0f
|
||||||
|
while (y < h) {
|
||||||
|
var x = 0f
|
||||||
|
while (x < w) {
|
||||||
|
drawHexTile(x, y, tW, tH, color, stroke)
|
||||||
|
x += tW
|
||||||
|
}
|
||||||
|
y += tH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws two tessellating hexagons into a [tileWidth] × [tileHeight] tile,
|
||||||
|
* matching the iOS `HoneycombTextureCache` geometry.
|
||||||
|
*/
|
||||||
|
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawHexTile(
|
||||||
|
ox: Float,
|
||||||
|
oy: Float,
|
||||||
|
tileWidth: Float,
|
||||||
|
tileHeight: Float,
|
||||||
|
color: Color,
|
||||||
|
stroke: Stroke,
|
||||||
|
) {
|
||||||
|
val halfW = tileWidth / 2f
|
||||||
|
// Side = halfW * (2 / √3); top-offset is side/2.
|
||||||
|
val side = halfW * 2f / sqrt(3f)
|
||||||
|
val topOffset = side / 2f
|
||||||
|
val midOffset = topOffset + side // == 1.5 * side
|
||||||
|
|
||||||
|
// Hex 1 — top hexagon.
|
||||||
|
val hex1 = Path().apply {
|
||||||
|
moveTo(ox + halfW, oy + 0f)
|
||||||
|
lineTo(ox + tileWidth, oy + topOffset)
|
||||||
|
lineTo(ox + tileWidth, oy + midOffset)
|
||||||
|
lineTo(ox + halfW, oy + midOffset + topOffset)
|
||||||
|
lineTo(ox + 0f, oy + midOffset)
|
||||||
|
lineTo(ox + 0f, oy + topOffset)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(hex1, color = color, style = stroke)
|
||||||
|
|
||||||
|
// Hex 2 — offset sibling for tessellation.
|
||||||
|
val y2 = oy + midOffset
|
||||||
|
val hex2 = Path().apply {
|
||||||
|
moveTo(ox + tileWidth, y2)
|
||||||
|
lineTo(ox + tileWidth + halfW, y2 + topOffset)
|
||||||
|
lineTo(ox + tileWidth + halfW, y2 + midOffset)
|
||||||
|
lineTo(ox + tileWidth, y2 + midOffset + topOffset)
|
||||||
|
lineTo(ox + halfW, y2 + midOffset)
|
||||||
|
lineTo(ox + halfW, y2 + topOffset)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(hex2, color = color, style = stroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OrganicCard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple organic card wrapper: organic-radius clipping, solid [background],
|
||||||
|
* optional [blobVariation] / [blobSeed] backdrop glow.
|
||||||
|
*
|
||||||
|
* Colors are supplied by the caller — this module intentionally avoids
|
||||||
|
* referencing theme tokens (Stream A owns those).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OrganicCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
background: Color,
|
||||||
|
accent: Color,
|
||||||
|
showBlob: Boolean = true,
|
||||||
|
blobVariation: Int = 0,
|
||||||
|
blobSeed: Long = 0L,
|
||||||
|
cornerRadius: Dp = OrganicRadius.blob,
|
||||||
|
content: @Composable BoxScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(cornerRadius))
|
||||||
|
.background(background),
|
||||||
|
) {
|
||||||
|
if (showBlob) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(BlobShape(variation = blobVariation, seed = blobSeed))
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
accent.copy(alpha = 0.08f),
|
||||||
|
accent.copy(alpha = 0.02f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.TopStart,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tokens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organic corner-radius scale. Values match iOS `AppRadius` point values
|
||||||
|
* (points == dp), plus a `blob` value for the signature organic card radius
|
||||||
|
* (iOS `OrganicRoundedRectangle` cornerRadius 28).
|
||||||
|
*/
|
||||||
|
object OrganicRadius {
|
||||||
|
val xs: Dp = 4.dp
|
||||||
|
val sm: Dp = 8.dp
|
||||||
|
val md: Dp = 12.dp
|
||||||
|
val lg: Dp = 16.dp
|
||||||
|
val xl: Dp = 20.dp
|
||||||
|
|
||||||
|
/** Signature organic card radius — matches iOS `OrganicRoundedRectangle(28)`. */
|
||||||
|
val blob: Dp = 28.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organic spacing scale matching iOS `OrganicSpacing`.
|
||||||
|
*/
|
||||||
|
object OrganicSpacing {
|
||||||
|
val compact: Dp = 8.dp
|
||||||
|
val cozy: Dp = 20.dp
|
||||||
|
val comfortable: Dp = 24.dp
|
||||||
|
val spacious: Dp = 32.dp
|
||||||
|
val airy: Dp = 40.dp
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.tt.honeyDue.ui.design
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for organic design primitives ported from iOS OrganicDesign.swift.
|
||||||
|
*
|
||||||
|
* These tests are deterministic — they verify that the same input produces
|
||||||
|
* the same geometry so UI snapshot regressions and cross-platform parity
|
||||||
|
* can be asserted without rendering.
|
||||||
|
*/
|
||||||
|
class OrganicDesignTest {
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Determinism: same seed + size → identical path
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blobShapeIsDeterministicForSameSeedAndSize() {
|
||||||
|
val size = Size(100f, 100f)
|
||||||
|
val a = BlobShape.serializePath(size = size, variation = 0, seed = 42L)
|
||||||
|
val b = BlobShape.serializePath(size = size, variation = 0, seed = 42L)
|
||||||
|
assertEquals(a, b, "BlobShape must produce identical paths for identical inputs")
|
||||||
|
assertTrue(a.isNotEmpty(), "Serialized path must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blobShapeIsDeterministicAcrossMultipleInvocations() {
|
||||||
|
val size = Size(256f, 128f)
|
||||||
|
val runs = List(5) {
|
||||||
|
BlobShape.serializePath(size = size, variation = 1, seed = 7L)
|
||||||
|
}
|
||||||
|
val first = runs.first()
|
||||||
|
runs.forEach { assertEquals(first, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Variations: N distinct shapes, matching iOS OrganicBlobShape count
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blobShapeHasExpectedVariationCountMatchingIos() {
|
||||||
|
// iOS OrganicBlobShape switches on variation % 3 (cloud / pebble / leaf).
|
||||||
|
assertEquals(3, BlobShape.VARIATION_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blobShapeVariationsProduceDistinctPaths() {
|
||||||
|
val size = Size(200f, 200f)
|
||||||
|
val paths = (0 until BlobShape.VARIATION_COUNT).map { v ->
|
||||||
|
BlobShape.serializePath(size = size, variation = v, seed = 0L)
|
||||||
|
}
|
||||||
|
// Every pair must differ.
|
||||||
|
for (i in paths.indices) {
|
||||||
|
for (j in i + 1 until paths.size) {
|
||||||
|
assertNotEquals(
|
||||||
|
paths[i],
|
||||||
|
paths[j],
|
||||||
|
"variation $i and $j must produce distinct paths"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blobShapeVariationIndexWrapsModulo() {
|
||||||
|
val size = Size(100f, 100f)
|
||||||
|
val base = BlobShape.serializePath(size = size, variation = 0, seed = 0L)
|
||||||
|
val wrapped = BlobShape.serializePath(
|
||||||
|
size = size,
|
||||||
|
variation = BlobShape.VARIATION_COUNT,
|
||||||
|
seed = 0L
|
||||||
|
)
|
||||||
|
assertEquals(base, wrapped, "variation index must wrap modulo VARIATION_COUNT")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blobShapeDifferentSeedsProduceDifferentPaths() {
|
||||||
|
val size = Size(100f, 100f)
|
||||||
|
val a = BlobShape.serializePath(size = size, variation = 0, seed = 1L)
|
||||||
|
val b = BlobShape.serializePath(size = size, variation = 0, seed = 2L)
|
||||||
|
assertNotEquals(a, b, "different seeds must perturb the path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// RadialGlow: parameter API surface (not a pixel test)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun radialGlowParamsExposesColorCenterAndRadius() {
|
||||||
|
val params = RadialGlowParams(
|
||||||
|
color = Color.Red,
|
||||||
|
center = Offset(10f, 20f),
|
||||||
|
radius = 50f
|
||||||
|
)
|
||||||
|
assertEquals(Color.Red, params.color)
|
||||||
|
assertEquals(Offset(10f, 20f), params.center)
|
||||||
|
assertEquals(50f, params.radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun radialGlowParamsEqualityIsValueBased() {
|
||||||
|
val a = RadialGlowParams(Color.Blue, Offset(1f, 2f), 3f)
|
||||||
|
val b = RadialGlowParams(Color.Blue, Offset(1f, 2f), 3f)
|
||||||
|
assertEquals(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// OrganicRadius: constants match iOS point-values
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun organicRadiusConstantsMatchIosValues() {
|
||||||
|
// iOS AppRadius (points == dp):
|
||||||
|
// xs 4, sm 8, md 12, lg 16, xl 20, xxl 24, full 9999
|
||||||
|
// iOS OrganicRoundedRectangle default cornerRadius == 28 → blob.
|
||||||
|
assertEquals(4.dp, OrganicRadius.xs)
|
||||||
|
assertEquals(8.dp, OrganicRadius.sm)
|
||||||
|
assertEquals(12.dp, OrganicRadius.md)
|
||||||
|
assertEquals(16.dp, OrganicRadius.lg)
|
||||||
|
assertEquals(20.dp, OrganicRadius.xl)
|
||||||
|
assertEquals(28.dp, OrganicRadius.blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// OrganicSpacing: matches iOS OrganicSpacing (compact/cozy/…)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun organicSpacingConstantsMatchIosValues() {
|
||||||
|
// iOS OrganicSpacing:
|
||||||
|
// compact 8, cozy 20, comfortable 24, spacious 32, airy 40
|
||||||
|
assertEquals(8.dp, OrganicSpacing.compact)
|
||||||
|
assertEquals(20.dp, OrganicSpacing.cozy)
|
||||||
|
assertEquals(24.dp, OrganicSpacing.comfortable)
|
||||||
|
assertEquals(32.dp, OrganicSpacing.spacious)
|
||||||
|
assertEquals(40.dp, OrganicSpacing.airy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Honeycomb overlay: configuration exposes tile size + color + opacity
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun honeycombOverlayConfigDefaultsMatchIos() {
|
||||||
|
// iOS HoneycombTextureCache: tile 60 x 103.92, stroke #C4856A, 0.8pt.
|
||||||
|
val cfg = HoneycombOverlayConfig()
|
||||||
|
assertEquals(60f, cfg.tileWidth)
|
||||||
|
assertEquals(103.92f, cfg.tileHeight)
|
||||||
|
// #C4856A opaque — iOS honeycomb stroke color.
|
||||||
|
assertEquals(Color(0xFFC4856A), cfg.strokeColor)
|
||||||
|
assertEquals(0.8f, cfg.strokeWidth)
|
||||||
|
assertEquals(0.10f, cfg.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user