*/
function openstation_mio_portrait_glow_shells() {
return array(
array( 1.0, 0.1 ),
array( 0.6, 0.14 ),
array( 0.28, 0.2 ),
);
}
/**
* Round to 2dp the same way the TypeScript `fix()` does.
*
* `number_format` is not enough on its own: PHP renders a negative
* value that rounds to zero as `-0.00`, and so does JavaScript's
* `toFixed`, so both sides normalise it. Two renderers reaching zero
* from opposite sides have to agree.
*
* @param float $value Value to format.
* @return string Fixed 2dp representation.
*/
function openstation_mio_portrait_fix( $value ) {
$rounded = round( (float) $value, 2 );
if ( 0.0 === $rounded ) {
$rounded = 0.0;
}
return number_format( $rounded, 2, '.', '' );
}
/**
* A 24-bit RGB int as `#rrggbb`.
*
* @param int $rgb Packed colour.
* @return string Hex colour.
*/
function openstation_mio_portrait_hex( $rgb ) {
return '#' . str_pad( dechex( (int) $rgb & 0xffffff ), 6, '0', STR_PAD_LEFT );
}
/**
* HSL to packed 24-bit RGB. Mirrors `hslToRgbInt()` in `chroma.ts`.
*
* @param float $h Hue in degrees; wrapped, so -30 and 330 agree.
* @param float $s Saturation, 0-1.
* @param float $l Lightness, 0-1.
* @return int Packed colour.
*/
function openstation_mio_hsl_to_rgb_int( $h, $s, $l ) {
$hue = fmod( fmod( (float) $h, 360.0 ) + 360.0, 360.0 );
$sat = min( 1.0, max( 0.0, (float) $s ) );
$lig = min( 1.0, max( 0.0, (float) $l ) );
$c = ( 1.0 - abs( 2.0 * $lig - 1.0 ) ) * $sat;
$hp = $hue / 60.0;
$x = $c * ( 1.0 - abs( fmod( $hp, 2.0 ) - 1.0 ) );
$r = 0.0;
$g = 0.0;
$b = 0.0;
if ( $hp < 1 ) {
$r = $c;
$g = $x;
} elseif ( $hp < 2 ) {
$r = $x;
$g = $c;
} elseif ( $hp < 3 ) {
$g = $c;
$b = $x;
} elseif ( $hp < 4 ) {
$g = $x;
$b = $c;
} elseif ( $hp < 5 ) {
$r = $x;
$b = $c;
} else {
$r = $c;
$b = $x;
}
$m = $lig - $c / 2.0;
$to8 = static function ( $v ) use ( $m ) {
// PHP rounds half away from zero and JS rounds half up. The
// channel values here are never exactly .5 in practice, but
// matching JS explicitly costs nothing and removes the doubt.
return (int) min( 255, max( 0, floor( ( $v + $m ) * 255.0 + 0.5 ) ) );
};
return ( $to8( $r ) << 16 ) | ( $to8( $g ) << 8 ) | $to8( $b );
}
/**
* The per-stop colour ramp. Mirrors `chromaRing()` with no hologram
* (`view`), no drift (`phase`) and no spin, which is what a still
* portrait has: nothing has elapsed.
*
* @param int $count Number of stops.
* @param array $appearance Resolved appearance.
* @return int[] Packed colours.
*/
function openstation_mio_portrait_ring( $count, $appearance ) {
$n = max( 1, (int) round( $count ) );
$out = array();
for ( $i = 0; $i < $n; $i++ ) {
$t = $i / $n;
// `hueLoop` walks the span out and back on a raised cosine, so
// both ends of the ramp are the same colour by construction and
// there is no seam. See the long note in `chroma.ts`.
$shifted = fmod( fmod( $t - $appearance['hueAngle'] / 360.0, 1.0 ) + 1.0, 1.0 );
$ramp = $appearance['hueLoop']
? 0.5 - 0.5 * cos( $shifted * M_PI * 2.0 )
: $shifted;
$hue = $appearance['hueStart'] + $appearance['hueSpan'] * $ramp;
// Cosine hump peaking at t = 1/3: the lit side of the ring.
$lift = 0.5 + 0.5 * cos( ( $t - 1.0 / 3.0 ) * M_PI * 2.0 );
$lightness = $appearance['lightness'] * ( 0.72 + 0.28 * $lift );
$out[] = openstation_mio_hsl_to_rgb_int( $hue, $appearance['saturation'], $lightness );
}
return $out;
}
/**
* The rest silhouette's deviation from a circle, at one angle.
*
* Mirrors `presetDeviation()` in `src/mio/shape.ts`. Every figurative
* preset is authored against an "upright phase" where 0 is straight
* up, because screen coordinates put -pi/2 at the top and one dropped
* sign there is a shape that ships upside down.
*
* @param float $angle Rest angle, radians.
* @param array $physics Resolved physics.
* @return float Deviation.
*/
function openstation_mio_preset_deviation( $angle, $physics ) {
$half_pi = M_PI / 2.0;
$tau = M_PI * 2.0;
$upright = fmod( fmod( $angle + $half_pi, $tau ) + $tau, $tau );
$crest = static function ( $cosine, $power ) {
return pow( 0.5 + 0.5 * $cosine, $power );
};
switch ( $physics['shapePreset'] ) {
case 'circle':
return 0.0;
case 'ghost':
// Authored in the raw screen angle: both terms are windowed
// on sin(theta), which is already the underside.
$under = max( 0.0, sin( $angle ) );
$n = 2.0 + 3.2 * $under;
$c = abs( cos( $angle ) );
$s = abs( sin( $angle ) );
$square = 1.0 / pow( pow( $c, $n ) + pow( $s, $n ), 1.0 / $n ) - 1.0;
$feet = -0.17 * pow( $under, 1.4 ) * cos( 6.0 * $angle );
return $square + $feet;
case 'potato':
return 0.16 * cos( 2.0 * $angle + 0.9 )
+ 0.095 * cos( 3.0 * $angle - 2.1 )
+ 0.036 * cos( 5.0 * $angle + 1.3 )
+ 0.019 * cos( 7.0 * $angle - 0.4 );
case 'star':
return 0.58 * ( $crest( cos( 5.0 * $upright ), 3 ) - 0.3125 );
case 'flower':
return 0.34 * ( $crest( cos( 6.0 * $upright ), 2 ) - 0.375 );
case 'diamond':
return 0.34 * ( $crest( cos( 4.0 * $upright ), 2 ) - 0.375 );
case 'drop':
return 0.72 * ( pow( max( 0.0, cos( $upright ) ), 8 ) - 0.1367 );
case 'cloud':
$up = max( 0.0, cos( $upright ) );
$down = max( 0.0, -cos( $upright ) );
return 0.34 * (
sqrt( $up ) * ( 0.5 + 0.5 * cos( 5.0 * $upright ) )
- 0.7 * $down * $down
- 0.0247
);
case 'heart':
$fold = $upright > M_PI ? $tau - $upright : $upright;
$cleft = -0.34 * pow( max( 0.0, cos( $upright ) ), 6 );
$lobes = 0.3 * pow( max( 0.0, cos( $fold - 1.0 ) ), 3 );
$tip = 0.34 * pow( max( 0.0, -cos( $upright ) ), 8 );
return $cleft + $lobes + $tip + 0.02;
case 'custom':
$lobes = (int) round( $physics['shapeLobes'] );
if ( $lobes < 2 ) {
return 0.0;
}
return ( 1.0 / ( 1.0 + $lobes * $lobes ) ) * cos( $lobes * $angle );
default:
// 'blob': a corner up, so a flat side sits along the bottom.
return 0.05 * cos( 3.0 * ( $angle + $half_pi ) );
}
}
/**
* The rest silhouette as a multiplier on the radius, at one angle.
*
* Mirrors `shapeProfile()`.
*
* @param float $angle Rest angle, radians.
* @param array $physics Resolved physics.
* @return float Radius multiplier.
*/
function openstation_mio_shape_profile( $angle, $physics ) {
if ( $physics['shapeAmount'] <= 0 ) {
return 1.0;
}
$upright = $angle - ( $physics['shapeAngle'] * M_PI ) / 180.0;
return 1.0 + $physics['shapeAmount'] * openstation_mio_preset_deviation( $upright, $physics );
}
/**
* The closed outline, as a cubic path centred on the origin.
*
* Sampled from the rest profile and joined with the Catmull-Rom to
* bezier conversion the live renderer applies to its rim; sampling
* alone reads as a polygon at these sizes.
*
* @param array $physics Resolved physics.
* @param float $radius Base radius.
* @return string SVG path data.
*/
function openstation_mio_portrait_path( $physics, $radius ) {
$n = OPENSTATION_MIO_PORTRAIT_RIM_SAMPLES;
$pts = array();
for ( $i = 0; $i < $n; $i++ ) {
$angle = ( $i / $n ) * M_PI * 2.0;
$r = $radius * openstation_mio_shape_profile( $angle, $physics );
$pts[] = array( $r * cos( $angle ), $r * sin( $angle ) );
}
$at = static function ( $i ) use ( $pts, $n ) {
return $pts[ ( ( $i % $n ) + $n ) % $n ];
};
$d = 'M' . openstation_mio_portrait_fix( $pts[0][0] ) . ' ' . openstation_mio_portrait_fix( $pts[0][1] );
for ( $i = 0; $i < $n; $i++ ) {
$p0 = $at( $i - 1 );
$p1 = $at( $i );
$p2 = $at( $i + 1 );
$p3 = $at( $i + 2 );
$c1x = $p1[0] + ( $p2[0] - $p0[0] ) / 6.0;
$c1y = $p1[1] + ( $p2[1] - $p0[1] ) / 6.0;
$c2x = $p2[0] - ( $p3[0] - $p1[0] ) / 6.0;
$c2y = $p2[1] - ( $p3[1] - $p1[1] ) / 6.0;
$d .= 'C' . openstation_mio_portrait_fix( $c1x ) . ' ' . openstation_mio_portrait_fix( $c1y )
. ',' . openstation_mio_portrait_fix( $c2x ) . ' ' . openstation_mio_portrait_fix( $c2y )
. ',' . openstation_mio_portrait_fix( $p2[0] ) . ' ' . openstation_mio_portrait_fix( $p2[1] );
}
return $d . 'Z';
}
/**
* How far the outline reaches, as a multiple of the radius.
*
* Every preset subtracts its own mean, so they share an average radius
* but not a peak: a teardrop reaches 1.62x, a star 1.40x, a circle
* 1.00x. A box drawn for the circle amputates the teardrop's tip, so
* the box is measured rather than assumed.
*
* @param array $physics Resolved physics.
* @return float Peak multiplier.
*/
function openstation_mio_portrait_extent( $physics ) {
$n = OPENSTATION_MIO_PORTRAIT_RIM_SAMPLES;
$max = 0.0;
for ( $i = 0; $i < $n; $i++ ) {
$max = max( $max, openstation_mio_shape_profile( ( $i / $n ) * M_PI * 2.0, $physics ) );
}
return $max;
}
/**
* Draw a Mio at rest.
*
* The look is taken as given: callers hand it the output of
* `openstation_mio_clamp_look()`, never raw storage.
*
* `$id_suffix` is appended to every internal id. The markup defines
* the outline and the gradient once and references them, so two
* portraits inlined into one document with the same ids would both
* render the first one's shape, silently. A portrait written to its
* own file or used as an `img` source is its own document and needs
* nothing.
*
* @param array $look Partial look: `appearance` and `physics`.
* @param int $size Rendered width and height, in pixels.
* @param string $id_suffix Appended to internal ids.
* @return string SVG markup.
*/
function openstation_mio_portrait_svg( $look = array(), $size = 96, $id_suffix = '' ) {
$defaults = openstation_mio_default_config();
$appearance = array_merge(
$defaults['appearance'],
isset( $look['appearance'] ) && is_array( $look['appearance'] ) ? $look['appearance'] : array()
);
$physics = array_merge(
$defaults['physics'],
isset( $look['physics'] ) && is_array( $look['physics'] ) ? $look['physics'] : array()
);
// The shipped defaults write colours as CSS hex strings because
// that is what reads well in a config array; a clamped look carries
// them as ints. Normalise so this draws the same either way.
$appearance['bodyColor'] = openstation_mio_color_int( $appearance['bodyColor'] );
$appearance['eyeColor'] = openstation_mio_color_int( $appearance['eyeColor'] );
$appearance['linerColor'] = openstation_mio_color_int( $appearance['linerColor'] );
// Only [A-Za-z0-9_-] survives, so a caller cannot close the
// attribute and write markup through this parameter.
$uid = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $id_suffix );
$ring_id = 'r' . $uid;
$shape_id = 's' . $uid;
$clip_id = 'c' . $uid;
// Work on a canonical 100-unit radius and let the viewBox scale it,
// so the same path serves a 24px avatar and a 176px hero.
$radius = 100.0;
$scale = $radius / $defaults['appearance']['radius'];
$stroke = $appearance['outlineWidth'] * $scale;
$liner = $appearance['linerWidth'] * $scale;
$reach = ( $appearance['glow'] / 10.0 ) * $radius * 0.18;
$shells = openstation_mio_portrait_glow_shells();
$half = $radius * openstation_mio_portrait_extent( $physics )
+ $stroke / 2.0
+ $reach * $shells[0][0];
$box = openstation_mio_portrait_fix( $half );
$span = openstation_mio_portrait_fix( $half * 2.0 );
$d = openstation_mio_portrait_path( $physics, $radius );
$ring = openstation_mio_portrait_ring( OPENSTATION_MIO_PORTRAIT_RING_SAMPLES, $appearance );
$stops = '';
$last = count( $ring ) - 1;
foreach ( $ring as $i => $rgb ) {
$offset = openstation_mio_portrait_fix( ( $i / $last ) * 100.0 );
$stops .= '';
}
$glow = '';
foreach ( $shells as $shell ) {
list( $spread, $alpha ) = $shell;
$glow .= '';
}
// At rest the body is undeformed, so every squash factor in
// `eyeLayout()` is exactly 1 and the gaze and blink terms are zero.
// What is left is the resting face.
$eye_h = $radius * $appearance['eyeScale'];
$eye_w = $eye_h * 0.46;
$eye_gap = $radius * 0.28;
$eye_y = -$radius * 0.02 - $eye_h / 2.0;
$eye = static function ( $cx ) use ( $eye_w, $eye_h, $eye_y, $appearance ) {
return '';
};
// The inner line, clipped to the body.
//
// SVG strokes are centred on their path and cannot be offset to one
// side, so the line is drawn at the full width it would need if it
// reached both ways — `stroke + liner * 2` — and the clip throws
// the outer half away. What is left runs from the outline inward,
// and the chroma stroke below is painted over its inner reach, so
// the visible white is exactly the band between the two. That is
// the same geometry `fillLiner()` produces in the live renderer, by
// the only means SVG offers.
$line = '';
if ( $liner > 0 ) {
$line = '';
}
return '';
}