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:
Trey T
2026-04-18 12:34:47 -05:00
parent dcab30f862
commit db181c0d6a
2 changed files with 601 additions and 0 deletions

View File

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