From 7b0c01560c082f7c6286913c1d1ef5289fe5f78e Mon Sep 17 00:00:00 2001
From: Rokojori
Date: Mon, 6 Jul 2026 22:05:27 +0200
Subject: [PATCH] history: rojo chibi bone animator prototype
Co-Authored-By: Claude Sonnet 4.6
---
src/rojos/rojo-animator.ts | 164 ++++++++++
src/rojos/rojo-base.svg | 298 ++++++++++++++++++
.../history/2026/07-July/06-Monday/index.html | 50 ++-
workspace/history/index.html | 2 +-
workspace/index.html | 28 +-
5 files changed, 535 insertions(+), 7 deletions(-)
create mode 100644 src/rojos/rojo-animator.ts
create mode 100644 src/rojos/rojo-base.svg
diff --git a/src/rojos/rojo-animator.ts b/src/rojos/rojo-animator.ts
new file mode 100644
index 0000000..c20f52a
--- /dev/null
+++ b/src/rojos/rojo-animator.ts
@@ -0,0 +1,164 @@
+const InkscapeNS = "http://www.inkscape.org/namespaces/inkscape";
+
+const N = "(-?[\\d.eE+]+)";
+const S = "[,\\s]+";
+
+const TranslateRE = new RegExp( `translate\\(\\s*${ N }${ S }${ N }\\s*\\)` );
+const MatrixRE = new RegExp( `matrix\\(\\s*${ N }${ S }${ N }${ S }${ N }${ S }${ N }${ S }${ N }${ S }${ N }\\s*\\)` );
+const RotateRE = new RegExp( `rotate\\(\\s*${ N }(?:${ S }${ N }${ S }${ N })?\\s*\\)` );
+
+function applyTransformStr( s: string, x: number, y: number ): [ number, number ]
+{
+ if ( null == s || "" === s ) { return [ x, y ]; }
+
+ let m = s.match( TranslateRE );
+
+ if ( null != m )
+ {
+ return [ x + parseFloat( m[ 1 ] ), y + parseFloat( m[ 2 ] ) ];
+ }
+
+ m = s.match( MatrixRE );
+
+ if ( null != m )
+ {
+ const a = parseFloat( m[ 1 ] ), b = parseFloat( m[ 2 ] );
+ const c = parseFloat( m[ 3 ] ), d = parseFloat( m[ 4 ] );
+ const e = parseFloat( m[ 5 ] ), f = parseFloat( m[ 6 ] );
+ return [ a * x + c * y + e, b * x + d * y + f ];
+ }
+
+ m = s.match( RotateRE );
+
+ if ( null != m )
+ {
+ const angle = parseFloat( m[ 1 ] ) * Math.PI / 180;
+ const cos = Math.cos( angle );
+ const sin = Math.sin( angle );
+ const cx = null != m[ 2 ] ? parseFloat( m[ 2 ] ) : 0;
+ const cy = null != m[ 3 ] ? parseFloat( m[ 3 ] ) : 0;
+ const dx = x - cx;
+ const dy = y - cy;
+ return [ cx + dx * cos - dy * sin, cy + dx * sin + dy * cos ];
+ }
+
+ return [ x, y ];
+}
+
+export class BoneData
+{
+ group: SVGGElement = null;
+ name: string = "";
+ restTransform: string = "";
+ pivotLocalX: number = 0;
+ pivotLocalY: number = 0;
+}
+
+export class RojoAnimator
+{
+ svgRoot: SVGSVGElement = null;
+ Bones: Map = new Map();
+
+ static create( svgRoot: SVGSVGElement )
+ {
+ let ra = new RojoAnimator();
+ ra.svgRoot = svgRoot;
+ ra._processBones();
+ ra._stripInkscape();
+ return ra;
+ }
+
+ _label( el: Element )
+ {
+ return el.getAttributeNS( InkscapeNS, "label" ) ?? "";
+ }
+
+ _processBones()
+ {
+ const groups = Array.from( this.svgRoot.querySelectorAll( "g" ) );
+
+ for ( const g of groups )
+ {
+ const label = this._label( g );
+
+ if ( !label.endsWith( "-transform" ) ) { continue; }
+
+ const name = label.slice( 0, label.length - "-transform".length );
+ const restTransform = g.getAttribute( "transform" ) ?? "";
+ const pivotLabel = name + "-pivot";
+ let pivotCircle: Element = null;
+
+ for ( const child of Array.from( g.children ) )
+ {
+ if ( "circle" === child.localName && pivotLabel === this._label( child ) )
+ {
+ pivotCircle = child;
+ break;
+ }
+ }
+
+ if ( null == pivotCircle ) { continue; }
+
+ const cx = parseFloat( pivotCircle.getAttribute( "cx" ) ?? "0" );
+ const cy = parseFloat( pivotCircle.getAttribute( "cy" ) ?? "0" );
+ const pivotTransform = pivotCircle.getAttribute( "transform" ) ?? "";
+ const [ lx, ly ] = applyTransformStr( pivotTransform, cx, cy );
+
+ pivotCircle.remove();
+
+ const bone = new BoneData();
+ bone.group = g as SVGGElement;
+ bone.name = name;
+ bone.restTransform = restTransform;
+ bone.pivotLocalX = lx;
+ bone.pivotLocalY = ly;
+
+ this.Bones.set( name, bone );
+ }
+ }
+
+ _stripInkscape()
+ {
+ const namedview = Array.from( this.svgRoot.children )
+ .find( el => "namedview" === el.localName );
+
+ if ( null != namedview ) { namedview.remove(); }
+
+ const all: Element[] = [ this.svgRoot, ...Array.from( this.svgRoot.querySelectorAll( "*" ) ) ];
+
+ for ( const el of all )
+ {
+ const toRemove: string[] = [];
+
+ for ( let i = 0; i < el.attributes.length; i++ )
+ {
+ const { name } = el.attributes[ i ];
+
+ if ( name.startsWith( "inkscape:" ) || name.startsWith( "sodipodi:" ) )
+ {
+ toRemove.push( name );
+ }
+ }
+
+ for ( const attr of toRemove )
+ {
+ el.removeAttribute( attr );
+ }
+ }
+ }
+
+ setBoneAngle( name: string, degrees: number )
+ {
+ const bone = this.Bones.get( name );
+
+ if ( null == bone ) { return; }
+
+ const rest = bone.restTransform;
+ const lx = bone.pivotLocalX;
+ const ly = bone.pivotLocalY;
+ const rotation = `translate(${ lx },${ ly }) rotate(${ degrees }) translate(${ -lx },${ -ly })`;
+ const transform = "" === rest ? rotation : `${ rest } ${ rotation }`;
+
+ bone.group.setAttribute( "transform", transform );
+ }
+}
diff --git a/src/rojos/rojo-base.svg b/src/rojos/rojo-base.svg
new file mode 100644
index 0000000..2fd4ca2
--- /dev/null
+++ b/src/rojos/rojo-base.svg
@@ -0,0 +1,298 @@
+
+
+
+
diff --git a/workspace/history/2026/07-July/06-Monday/index.html b/workspace/history/2026/07-July/06-Monday/index.html
index 813ca5e..6d03a78 100644
--- a/workspace/history/2026/07-July/06-Monday/index.html
+++ b/workspace/history/2026/07-July/06-Monday/index.html
@@ -16,7 +16,8 @@
File tree rename/delete, locale generator rewrite, locale documentation,
doc/ renamed to workspace/, fixed navigation header system,
- Guides/Actions workspace restructure, and Update History action rewrite.
+ Guides/Actions workspace restructure, Update History action rewrite,
+ and Rojo chibi bone animator prototype.
@@ -181,8 +182,6 @@
-
-
Update History Action: Commit and Push
@@ -205,6 +204,44 @@
+
+
Rojo Chibi Bone Animator Prototype
+
+ A 2D skeletal animation prototype was started for an in-app chibi character
+ intended to accompany AI agent interactions. The source artwork is an Inkscape
+ SVG (src/rojos/rojo-base.svg) where each body part is a
+ <g> labelled *-transform and contains a
+ <circle> labelled *-pivot that marks the
+ rotation point. The pivot circle carries an inverse transform so its
+ cx/cy sits at the exact world-space pivot regardless of the
+ bone's own transform.
+
+
+ src/rojos/rojo-animator.ts provides RojoAnimator,
+ which walks all *-transform groups on load, applies the pivot
+ circle's transform to its cx/cy to recover the local-space pivot,
+ removes the circles, and strips Inkscape namespaced attributes. At runtime,
+ setBoneAngle(name, degrees) writes
+ restTransform translate(lx,ly) rotate(θ) translate(-lx,-ly)
+ directly onto the group element — no graphics library required. The parser
+ handles all three transform types present in the SVG:
+ translate, matrix, and rotate(angle,cx,cy).
+
+
+ public/rojos/rojo-demo.html fetches the SVG, injects it into
+ the page, and runs a requestAnimationFrame loop with three
+ switchable animations: Idle (breathing sway), Walk
+ (legs and arms in opposite phase), and Wave (right arm wave).
+
+
+ src/rojos/rojo-animator.ts
+ public/rojos/rojo-demo.html
+ public/rojos/rojo-base.svg
+ SVG bone animation
+ no graphics library
+
+
+
@@ -292,7 +329,12 @@
server/localeGenerator.ts — complete rewrite (per-directory output) src/locales/generated/ — new: generated locale classes src/locales/LocaleManager.ts — static $ changed to static get $
- CLAUDE.md — updated to point to workspace/outline/index.html
+ CLAUDE.md — updated to point to workspace/outline/index.html
+ workspace/index.html — updated: added Guides and Actions cards
+ src/rojos/rojo-base.svg — new: Inkscape chibi character with bone/pivot structure
+ src/rojos/rojo-animator.ts — new: SVG bone animator (pivot parser + setBoneAngle)
+ public/rojos/rojo-base.svg — new: copy of SVG for static serving
+ public/rojos/rojo-demo.html — new: interactive animation demo (idle/walk/wave)
- Explains Roject's goals, features and future plans and actions that are used for
- the project.
+ Explains Roject's goals, features and future plans for the project.
Read more about the outline