2895 lines
100 KiB
HTML
2895 lines
100 KiB
HTML
<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Yeux sphériques — prototype</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light;
|
||
font-family:
|
||
Inter,
|
||
ui-sans-serif,
|
||
system-ui,
|
||
-apple-system,
|
||
BlinkMacSystemFont,
|
||
'Segoe UI',
|
||
sans-serif;
|
||
background: #f4f5f8;
|
||
color: #14161b;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
button,
|
||
input {
|
||
font: inherit;
|
||
}
|
||
|
||
.app {
|
||
display: grid;
|
||
grid-template-columns: minmax(360px, 1.15fr) minmax(350px, 0.85fr);
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.stage-column {
|
||
position: sticky;
|
||
top: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
padding: 48px;
|
||
background: #111316;
|
||
}
|
||
|
||
.stage {
|
||
width: min(70vh, 680px);
|
||
max-width: 100%;
|
||
}
|
||
|
||
.stage svg {
|
||
display: block;
|
||
width: 100%;
|
||
overflow: visible;
|
||
touch-action: none;
|
||
}
|
||
|
||
.head {
|
||
fill: #5b7fe5;
|
||
cursor: grab;
|
||
}
|
||
|
||
.head:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.eye {
|
||
fill: #111316;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.wire,
|
||
.equator,
|
||
.point {
|
||
pointer-events: none;
|
||
}
|
||
|
||
.wire {
|
||
fill: none;
|
||
stroke: rgb(255 255 255 / 24%);
|
||
stroke-width: 1;
|
||
}
|
||
|
||
.equator {
|
||
fill: none;
|
||
stroke: rgb(255 255 255 / 42%);
|
||
stroke-dasharray: 4 5;
|
||
stroke-width: 1;
|
||
}
|
||
|
||
.point {
|
||
fill: #51d6ff;
|
||
}
|
||
|
||
.selection-outline {
|
||
fill: none;
|
||
stroke: #5ce1ff;
|
||
stroke-dasharray: 3 3;
|
||
stroke-width: 1.5;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.editor-line {
|
||
fill: none;
|
||
stroke: #5ce1ff;
|
||
stroke-width: 1.25;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.editor-handle {
|
||
fill: white;
|
||
stroke: #22c9f2;
|
||
stroke-width: 2;
|
||
cursor: grab;
|
||
}
|
||
|
||
.editor-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.editor-handle[data-handle='width'] {
|
||
cursor: ew-resize;
|
||
}
|
||
|
||
.editor-handle[data-handle='height'] {
|
||
cursor: ns-resize;
|
||
}
|
||
|
||
.editor-handle[data-handle='scale'] {
|
||
cursor: nwse-resize;
|
||
}
|
||
|
||
.editor-label {
|
||
fill: white;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
font-size: 7px;
|
||
font-weight: 800;
|
||
paint-order: stroke;
|
||
pointer-events: none;
|
||
stroke: #111316;
|
||
stroke-width: 2.5px;
|
||
text-anchor: middle;
|
||
}
|
||
|
||
.view-roll-ring {
|
||
fill: none;
|
||
stroke: rgb(255 221 64 / 72%);
|
||
stroke-width: 1.5;
|
||
cursor: grab;
|
||
pointer-events: stroke;
|
||
transition: stroke-width 120ms ease;
|
||
}
|
||
|
||
.view-roll-ring:hover {
|
||
stroke-width: 3.5;
|
||
}
|
||
|
||
.view-roll-handle {
|
||
fill: #111316;
|
||
stroke: #ffdd40;
|
||
stroke-width: 2;
|
||
cursor: grab;
|
||
}
|
||
|
||
.view-roll-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.rotation-axis-ring {
|
||
fill: none;
|
||
stroke-width: 1.65;
|
||
cursor: grab;
|
||
opacity: 0.8;
|
||
pointer-events: stroke;
|
||
transition:
|
||
stroke-width 120ms ease,
|
||
opacity 120ms ease;
|
||
}
|
||
|
||
.rotation-axis-ring:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.rotation-axis-ring:hover {
|
||
stroke-width: 3.5;
|
||
opacity: 1;
|
||
}
|
||
|
||
.rotation-axis-ring.is-active,
|
||
.view-roll-ring.is-active {
|
||
stroke-width: 4.5;
|
||
opacity: 1;
|
||
}
|
||
|
||
.head.is-active {
|
||
stroke: #5ce1ff;
|
||
stroke-width: 3;
|
||
}
|
||
|
||
.rotation-axis-ring[data-rotation-axis='x'] {
|
||
stroke: #3ee66f;
|
||
}
|
||
|
||
.rotation-axis-ring[data-rotation-axis='y'] {
|
||
stroke: #ff4d4d;
|
||
}
|
||
|
||
.rotation-axis-ring[data-rotation-axis='z'] {
|
||
stroke: #4d77ff;
|
||
}
|
||
|
||
.stage-note {
|
||
margin: 22px 0 0;
|
||
color: rgb(255 255 255 / 64%);
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
text-align: center;
|
||
}
|
||
|
||
.controls-column {
|
||
padding: 42px clamp(24px, 4vw, 64px) 96px;
|
||
background: #f7f7f9;
|
||
}
|
||
|
||
.eyebrow {
|
||
margin: 0 0 10px;
|
||
color: #5577d7;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.13em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
h1 {
|
||
max-width: 680px;
|
||
margin: 0;
|
||
font-size: clamp(32px, 4.2vw, 58px);
|
||
letter-spacing: -0.055em;
|
||
line-height: 0.98;
|
||
}
|
||
|
||
.lede {
|
||
max-width: 640px;
|
||
margin: 18px 0 32px;
|
||
color: #656a74;
|
||
font-size: 15px;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.panel {
|
||
margin-top: 18px;
|
||
padding: 22px;
|
||
border: 1px solid #e3e4e8;
|
||
border-radius: 20px;
|
||
background: white;
|
||
box-shadow: 0 12px 35px rgb(25 28 36 / 5%);
|
||
}
|
||
|
||
.panel h2 {
|
||
margin: 0 0 4px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.panel-description {
|
||
margin: 0 0 20px;
|
||
color: #81858e;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.mode-tabs {
|
||
position: sticky;
|
||
z-index: 5;
|
||
top: 16px;
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 6px;
|
||
margin: 0 0 18px;
|
||
padding: 6px;
|
||
border: 1px solid #dfe1e7;
|
||
border-radius: 16px;
|
||
background: rgb(255 255 255 / 92%);
|
||
box-shadow: 0 10px 28px rgb(25 28 36 / 10%);
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
|
||
.mode-tab {
|
||
min-height: 38px;
|
||
border: 0;
|
||
border-radius: 11px;
|
||
background: transparent;
|
||
}
|
||
|
||
.mode-tab[aria-selected='true'] {
|
||
background: #111316;
|
||
color: white;
|
||
}
|
||
|
||
.mode-panel[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
.expression-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.expression-button {
|
||
position: relative;
|
||
min-height: 76px;
|
||
overflow: hidden;
|
||
padding: 4px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.expression-button svg {
|
||
display: block;
|
||
width: 100%;
|
||
height: 62px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.expression-button .expression-number {
|
||
position: absolute;
|
||
right: 6px;
|
||
bottom: 4px;
|
||
padding: 2px 4px;
|
||
border-radius: 5px;
|
||
background: rgb(255 255 255 / 80%);
|
||
color: #30343b;
|
||
font-size: 9px;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
}
|
||
|
||
.expression-button[aria-pressed='true'] .expression-number {
|
||
background: rgb(17 19 22 / 80%);
|
||
color: white;
|
||
}
|
||
|
||
.expression-add {
|
||
display: grid;
|
||
min-height: 76px;
|
||
place-items: center;
|
||
border-style: dashed;
|
||
color: #5c7fe7;
|
||
font-size: 28px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.expression-hint {
|
||
margin: 12px 0 0;
|
||
color: #81858e;
|
||
font-size: 11px;
|
||
text-align: center;
|
||
}
|
||
|
||
.expression-editor {
|
||
width: min(760px, calc(100vw - 32px));
|
||
max-height: calc(100vh - 32px);
|
||
overflow: hidden;
|
||
padding: 0;
|
||
border: 1px solid #dfe1e7;
|
||
border-radius: 24px;
|
||
background: #f5f6f8;
|
||
box-shadow: 0 28px 90px rgb(17 19 22 / 30%);
|
||
}
|
||
|
||
.expression-editor::backdrop {
|
||
background: rgb(17 19 22 / 54%);
|
||
backdrop-filter: blur(5px);
|
||
}
|
||
|
||
.expression-editor-shell {
|
||
display: grid;
|
||
max-height: calc(100vh - 32px);
|
||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||
}
|
||
|
||
.expression-editor-header,
|
||
.expression-editor-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 18px 22px;
|
||
background: white;
|
||
}
|
||
|
||
.expression-editor-header {
|
||
border-bottom: 1px solid #e3e4e8;
|
||
}
|
||
|
||
.expression-editor-header h2,
|
||
.expression-editor-header p {
|
||
margin: 0;
|
||
}
|
||
|
||
.expression-editor-header p {
|
||
margin-top: 3px;
|
||
color: #81858e;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.expression-editor-close {
|
||
width: 36px;
|
||
min-height: 36px;
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.expression-editor-body {
|
||
display: grid;
|
||
overflow: auto;
|
||
padding: 18px;
|
||
grid-template-columns: 190px minmax(0, 1fr);
|
||
gap: 18px;
|
||
}
|
||
|
||
.expression-editor-preview {
|
||
position: sticky;
|
||
top: 0;
|
||
align-self: start;
|
||
padding: 14px;
|
||
border: 1px solid #e3e4e8;
|
||
border-radius: 18px;
|
||
background: white;
|
||
}
|
||
|
||
.expression-editor-preview svg {
|
||
display: block;
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
}
|
||
|
||
.expression-editor-preview strong,
|
||
.expression-editor-preview span {
|
||
display: block;
|
||
text-align: center;
|
||
}
|
||
|
||
.expression-editor-preview span {
|
||
margin-top: 4px;
|
||
color: #81858e;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.expression-editor-fields {
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
|
||
.expression-editor-group {
|
||
padding: 14px;
|
||
border: 1px solid #e3e4e8;
|
||
border-radius: 16px;
|
||
background: white;
|
||
}
|
||
|
||
.expression-editor-group h3 {
|
||
margin: 0 0 12px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.expression-field {
|
||
display: grid;
|
||
align-items: center;
|
||
grid-template-columns: minmax(92px, 0.8fr) minmax(120px, 1fr) 66px;
|
||
gap: 10px;
|
||
}
|
||
|
||
.expression-field + .expression-field {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.expression-field > span {
|
||
color: #4b5059;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.expression-field input[type='number'] {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 7px 8px;
|
||
border: 1px solid #d6d8df;
|
||
border-radius: 8px;
|
||
background: #f7f8fa;
|
||
color: #24272d;
|
||
font: inherit;
|
||
font-size: 11px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.expression-editor-actions {
|
||
justify-content: flex-end;
|
||
border-top: 1px solid #e3e4e8;
|
||
}
|
||
|
||
.expression-editor-actions button {
|
||
min-width: 120px;
|
||
}
|
||
|
||
.expression-editor-save {
|
||
border-color: #111316;
|
||
background: #111316;
|
||
color: white;
|
||
}
|
||
|
||
@media (max-width: 620px) {
|
||
.expression-editor-body {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.expression-editor-preview {
|
||
position: static;
|
||
}
|
||
|
||
.expression-editor-preview svg {
|
||
max-height: 180px;
|
||
}
|
||
|
||
.expression-field {
|
||
grid-template-columns: 88px minmax(100px, 1fr) 60px;
|
||
}
|
||
}
|
||
|
||
.state-groups {
|
||
display: grid;
|
||
gap: 18px;
|
||
}
|
||
|
||
.state-group-title {
|
||
display: block;
|
||
margin-bottom: 9px;
|
||
color: #81858e;
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.state-buttons,
|
||
.state-pool {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 7px;
|
||
}
|
||
|
||
.state-button,
|
||
.pool-button {
|
||
min-height: 34px;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.state-summary {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 9px;
|
||
margin: 16px 0;
|
||
}
|
||
|
||
.state-summary div {
|
||
padding: 11px;
|
||
border-radius: 12px;
|
||
background: #f3f4f7;
|
||
}
|
||
|
||
.state-summary span,
|
||
.state-summary strong {
|
||
display: block;
|
||
}
|
||
|
||
.state-summary span {
|
||
margin-bottom: 4px;
|
||
color: #81858e;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.state-summary strong {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.button-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.button-row button {
|
||
flex: 1;
|
||
}
|
||
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
margin-bottom: 14px;
|
||
padding: 7px 10px;
|
||
border-radius: 999px;
|
||
background: #edf1ff;
|
||
color: #5577d7;
|
||
font-size: 11px;
|
||
font-weight: 750;
|
||
}
|
||
|
||
.status-pill::before {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
content: '';
|
||
}
|
||
|
||
.control {
|
||
display: grid;
|
||
gap: 9px;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.control:first-of-type {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.control-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
color: #3d4149;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.dimension-control {
|
||
margin-top: 22px;
|
||
}
|
||
|
||
.dimension-control:first-of-type {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.dimension-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
color: #3d4149;
|
||
font-size: 13px;
|
||
font-weight: 750;
|
||
}
|
||
|
||
.link-button {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 34px;
|
||
min-height: 34px;
|
||
padding: 0;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.link-button svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
stroke-width: 2;
|
||
}
|
||
|
||
.eye-pair-controls {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
}
|
||
|
||
.eye-control {
|
||
display: grid;
|
||
gap: 8px;
|
||
color: #676c76;
|
||
font-size: 12px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.eye-control span {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
output {
|
||
color: #5b7fe5;
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 750;
|
||
}
|
||
|
||
input[type='range'] {
|
||
width: 100%;
|
||
accent-color: #5b7fe5;
|
||
}
|
||
|
||
.preset-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
}
|
||
|
||
button {
|
||
min-height: 42px;
|
||
padding: 9px 12px;
|
||
border: 1px solid #dfe1e6;
|
||
border-radius: 12px;
|
||
background: #f7f7f9;
|
||
color: #363941;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
button:hover {
|
||
border-color: #bfc8e7;
|
||
background: #eef1fb;
|
||
}
|
||
|
||
button[aria-pressed='true'] {
|
||
border-color: #5b7fe5;
|
||
background: #5b7fe5;
|
||
color: white;
|
||
}
|
||
|
||
.switch-line {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-top: 14px;
|
||
color: #555a64;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.readout {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.readout span {
|
||
padding: 11px 12px;
|
||
border-radius: 12px;
|
||
background: #f3f4f7;
|
||
color: #676c76;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.readout .wide {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.reset {
|
||
width: 100%;
|
||
margin-top: 18px;
|
||
background: #111316;
|
||
color: white;
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.app {
|
||
display: block;
|
||
}
|
||
|
||
.stage-column {
|
||
z-index: 2;
|
||
height: min(54vh, 510px);
|
||
padding: 24px;
|
||
}
|
||
|
||
.stage {
|
||
width: min(43vh, 390px);
|
||
}
|
||
|
||
.stage-note {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.controls-column {
|
||
padding-top: 32px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- PROTOTYPE JETABLE : projection procédurale de deux capsules sur une sphère. -->
|
||
<div class="app">
|
||
<section class="stage-column" aria-label="Aperçu">
|
||
<div class="stage">
|
||
<svg id="avatar" viewBox="-150 -150 300 300" role="img" aria-labelledby="title desc">
|
||
<title id="title">Deux yeux capsules projetés sur une sphère</title>
|
||
<desc id="desc">
|
||
Les contours des yeux sont recalculés en direct selon la rotation de la tête et leurs
|
||
paramètres locaux.
|
||
</desc>
|
||
<defs>
|
||
<clipPath id="head-clip">
|
||
<circle id="head-clip-circle" cx="0" cy="0" r="120" />
|
||
</clipPath>
|
||
</defs>
|
||
<circle class="head" cx="0" cy="0" r="120" />
|
||
<g clip-path="url(#head-clip)">
|
||
<g id="wires"></g>
|
||
<path class="equator" id="equator" />
|
||
<path class="eye" id="left-eye" />
|
||
<path class="eye" id="right-eye" />
|
||
<g id="points"></g>
|
||
</g>
|
||
<g id="rotation-gizmo">
|
||
<path class="rotation-axis-ring" data-rotation-axis="x" id="axis-x-ring" />
|
||
<path class="rotation-axis-ring" data-rotation-axis="y" id="axis-y-ring" />
|
||
<path class="rotation-axis-ring" data-rotation-axis="z" id="axis-z-ring" />
|
||
<circle class="view-roll-ring" id="view-roll-ring" cx="116" cy="-116" r="27" />
|
||
<circle class="view-roll-handle" id="view-roll-handle" r="7" />
|
||
</g>
|
||
<g id="editor" visibility="hidden">
|
||
<path class="selection-outline" id="selection-outline" />
|
||
<path class="editor-line" id="width-guide" />
|
||
<path class="editor-line" id="height-guide" />
|
||
<path class="editor-line" id="rotation-guide" />
|
||
<path class="editor-line" id="spacing-guide" />
|
||
<circle class="editor-handle" data-handle="width" id="width-handle" r="5" />
|
||
<circle class="editor-handle" data-handle="height" id="height-handle" r="5" />
|
||
<circle class="editor-handle" data-handle="rotate" id="rotate-handle" r="5" />
|
||
<rect
|
||
class="editor-handle"
|
||
data-handle="scale"
|
||
id="scale-handle"
|
||
width="9"
|
||
height="9"
|
||
rx="2"
|
||
/>
|
||
<rect
|
||
class="editor-handle"
|
||
data-handle="spacing"
|
||
id="spacing-handle"
|
||
width="10"
|
||
height="10"
|
||
rx="2"
|
||
/>
|
||
<text class="editor-label" id="width-label">L</text>
|
||
<text class="editor-label" id="height-label">H</text>
|
||
<text class="editor-label" id="rotate-label">R</text>
|
||
<text class="editor-label" id="scale-label">S</text>
|
||
<text class="editor-label" id="spacing-label">E</text>
|
||
</g>
|
||
</svg>
|
||
<p class="stage-note">
|
||
Glisse la boule pour tourner la tête. Clique un œil pour afficher ses poignées :
|
||
largeur, hauteur, rotation, taille globale et espacement. Les anneaux colorés règlent
|
||
X/Y/Z dans le coin supérieur droit ; l’anneau jaune tourne autour de la caméra.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<main class="controls-column">
|
||
<p class="eyebrow">Prototype 02 · géométrie procédurale</p>
|
||
<h1>Deux yeux.<br />Une seule forme.</h1>
|
||
<p class="lede">
|
||
Chaque œil est un rectangle long totalement arrondi. La taille et l’inclinaison sont
|
||
définies localement, puis la rotation de tête déforme réellement le contour sur une sphère
|
||
3D avant de le ramener dans le SVG.
|
||
</p>
|
||
|
||
<nav class="mode-tabs" role="tablist" aria-label="Mode d’édition">
|
||
<button class="mode-tab" type="button" role="tab" data-mode="manual" aria-selected="true">
|
||
Manuel
|
||
</button>
|
||
<button
|
||
class="mode-tab"
|
||
type="button"
|
||
role="tab"
|
||
data-mode="expressions"
|
||
aria-selected="false"
|
||
>
|
||
Expressions
|
||
</button>
|
||
<button
|
||
class="mode-tab"
|
||
type="button"
|
||
role="tab"
|
||
data-mode="states"
|
||
aria-selected="false"
|
||
>
|
||
États
|
||
</button>
|
||
</nav>
|
||
|
||
<div class="mode-panel" id="mode-manual" role="tabpanel">
|
||
<section class="panel">
|
||
<h2>Rotation de la tête</h2>
|
||
<p class="panel-description">
|
||
Les trois axes utilisent de vraies rotations 3D successives. Le drag de la boule est
|
||
libre ; les anneaux colorés contraignent X/Y/Z et l’anneau jaune suit la caméra.
|
||
</p>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Rotation X · tangage (haut/bas)</span><output id="head-x-output"></output
|
||
></span>
|
||
<input id="head-x" type="range" min="-365" max="365" value="17" step="0.1" />
|
||
</label>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Rotation Y · lacet (gauche/droite)</span><output id="head-y-output"></output
|
||
></span>
|
||
<input id="head-y" type="range" min="-365" max="365" value="34" step="0.1" />
|
||
</label>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Rotation Z · roulis (inclinaison)</span><output id="head-z-output"></output
|
||
></span>
|
||
<input id="head-z" type="range" min="-365" max="365" value="-21" step="0.1" />
|
||
</label>
|
||
<div class="readout">
|
||
<span class="wide" id="arcball-output">arcball · q(1, 0, 0, 0)</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>Forme commune</h2>
|
||
<p class="panel-description">
|
||
Les deux yeux partagent la même capsule de base. On peut l’allonger, l’épaissir,
|
||
l’écarter et redimensionner tout le regard.
|
||
</p>
|
||
<div class="dimension-control">
|
||
<div class="dimension-head">
|
||
<span>Largeur</span>
|
||
<button
|
||
class="link-button"
|
||
type="button"
|
||
data-link="width"
|
||
aria-label="Lier les largeurs"
|
||
aria-pressed="true"
|
||
title="Lier ou délier les deux yeux"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.1 1.1" />
|
||
<path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.1-1.1" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="eye-pair-controls">
|
||
<label class="eye-control">
|
||
<span>Gauche <output id="width-left-output"></output></span>
|
||
<input id="width-left" type="range" min="12" max="100" value="20" step="0.1" />
|
||
</label>
|
||
<label class="eye-control">
|
||
<span>Droite <output id="width-right-output"></output></span>
|
||
<input id="width-right" type="range" min="12" max="100" value="20" step="0.1" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="dimension-control">
|
||
<div class="dimension-head">
|
||
<span>Hauteur</span>
|
||
<button
|
||
class="link-button"
|
||
type="button"
|
||
data-link="height"
|
||
aria-label="Lier les hauteurs"
|
||
aria-pressed="true"
|
||
title="Lier ou délier les deux yeux"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.1 1.1" />
|
||
<path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.1-1.1" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="eye-pair-controls">
|
||
<label class="eye-control">
|
||
<span>Gauche <output id="height-left-output"></output></span>
|
||
<input id="height-left" type="range" min="5" max="100" value="50" step="0.1" />
|
||
</label>
|
||
<label class="eye-control">
|
||
<span>Droite <output id="height-right-output"></output></span>
|
||
<input id="height-right" type="range" min="5" max="100" value="50" step="0.1" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="dimension-control">
|
||
<div class="dimension-head">
|
||
<span>Taille globale</span>
|
||
<button
|
||
class="link-button"
|
||
type="button"
|
||
data-link="scale"
|
||
aria-label="Lier les tailles"
|
||
aria-pressed="true"
|
||
title="Lier ou délier les deux yeux"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.1 1.1" />
|
||
<path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.1-1.1" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="eye-pair-controls">
|
||
<label class="eye-control">
|
||
<span>Gauche <output id="scale-left-output"></output></span>
|
||
<input id="scale-left" type="range" min="8" max="100" value="31.6" step="0.1" />
|
||
</label>
|
||
<label class="eye-control">
|
||
<span>Droite <output id="scale-right-output"></output></span>
|
||
<input id="scale-right" type="range" min="8" max="100" value="31.6" step="0.1" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Espacement</span><output id="spacing-output"></output
|
||
></span>
|
||
<input id="spacing" type="range" min="8" max="82" value="35" step="0.1" />
|
||
</label>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Position verticale</span><output id="latitude-output"></output
|
||
></span>
|
||
<input id="latitude" type="range" min="-42" max="42" value="-7" step="0.1" />
|
||
</label>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>Rotation des yeux</h2>
|
||
<p class="panel-description">
|
||
La rotation se fait avant la projection : même inclinée, la capsule épouse ensuite la
|
||
courbure de la tête.
|
||
</p>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Œil gauche</span><output id="left-angle-output"></output
|
||
></span>
|
||
<input id="left-angle" type="range" min="-365" max="365" value="0" step="0.1" />
|
||
</label>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Œil droit</span><output id="right-angle-output"></output
|
||
></span>
|
||
<input id="right-angle" type="range" min="-365" max="365" value="0" step="0.1" />
|
||
</label>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>Inspection</h2>
|
||
<p class="panel-description">
|
||
Les guides permettent de voir que le résultat vient de points 3D et non d’une simple
|
||
transformation CSS du SVG entier.
|
||
</p>
|
||
<label class="switch-line">
|
||
<span>Afficher les points échantillonnés</span>
|
||
<input id="show-points" type="checkbox" />
|
||
</label>
|
||
<label class="switch-line">
|
||
<span>Afficher le maillage sphérique</span>
|
||
<input id="show-wire" type="checkbox" />
|
||
</label>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Force de la perspective</span><output id="perspective-output"></output
|
||
></span>
|
||
<input id="perspective" type="range" min="0" max="3" value="1" step="0.01" />
|
||
</label>
|
||
<div class="readout">
|
||
<span id="visible-left">gauche · visible</span>
|
||
<span id="visible-right">droite · visible</span>
|
||
<span class="wide" id="head-radius-output">silhouette · rayon 120.00 u</span>
|
||
</div>
|
||
<button class="reset" id="reset" type="button">Réinitialiser</button>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="mode-panel" id="mode-expressions" role="tabpanel" hidden>
|
||
<section class="panel">
|
||
<span class="status-pill"
|
||
>Expression active <strong id="active-expression">00</strong></span
|
||
>
|
||
<h2>Presets d’expressions</h2>
|
||
<p class="panel-description">
|
||
Chaque preset mémorise la tête, les deux yeux et la perspective. Clique pour
|
||
l’appliquer ou double-clique pour modifier tous ses paramètres.
|
||
</p>
|
||
<div class="expression-grid" id="expression-grid"></div>
|
||
<p class="expression-hint">Double-clic · modifier le preset</p>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>Transition</h2>
|
||
<p class="panel-description">
|
||
Le ressort interpole toutes les propriétés simultanément. Une valeur élevée rejoint la
|
||
cible plus vite tout en conservant un léger amortissement.
|
||
</p>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Vitesse du ressort</span><output id="spring-speed-output">7.0</output></span
|
||
>
|
||
<input id="spring-speed" type="range" min="3" max="16" value="7" step="0.5" />
|
||
</label>
|
||
<label class="control">
|
||
<span class="control-head"
|
||
><span>Durée du clignement</span
|
||
><output id="blink-duration-output">320 ms</output></span
|
||
>
|
||
<input id="blink-duration" type="range" min="140" max="800" value="320" step="10" />
|
||
</label>
|
||
<div class="button-row">
|
||
<button type="button" id="blink-now">Cligner maintenant</button>
|
||
<button type="button" id="random-expression">Expression aléatoire</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="mode-panel" id="mode-states" role="tabpanel" hidden>
|
||
<section class="panel">
|
||
<span class="status-pill">État actif <strong id="active-state">idle</strong></span>
|
||
<h2>États animés</h2>
|
||
<p class="panel-description">
|
||
Un état choisit un pool d’expressions, une cadence de changement et une cadence de
|
||
clignement. Sélectionne un état puis lance-le pour observer la séquence.
|
||
</p>
|
||
<div class="state-groups" id="state-groups"></div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2 id="state-title">idle</h2>
|
||
<p class="panel-description" id="state-description"></p>
|
||
<strong>Pool d’expressions</strong>
|
||
<div class="state-pool" id="state-pool"></div>
|
||
<div class="state-summary">
|
||
<div>
|
||
<span>Changement d’expression</span>
|
||
<strong id="state-expression-cadence"></strong>
|
||
</div>
|
||
<div>
|
||
<span>Clignement</span>
|
||
<strong id="state-blink-cadence"></strong>
|
||
</div>
|
||
</div>
|
||
<label class="switch-line">
|
||
<span>Expressions automatiques</span>
|
||
<input id="auto-expression" type="checkbox" checked />
|
||
</label>
|
||
<label class="switch-line">
|
||
<span>Clignement automatique</span>
|
||
<input id="auto-blink" type="checkbox" checked />
|
||
</label>
|
||
<div class="button-row">
|
||
<button type="button" id="apply-state">Lancer cet état</button>
|
||
<button type="button" id="stop-state">Mettre en pause</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<dialog class="expression-editor" id="expression-editor">
|
||
<form class="expression-editor-shell" id="expression-editor-form" method="dialog">
|
||
<header class="expression-editor-header">
|
||
<div>
|
||
<h2 id="expression-editor-title">Modifier l’expression 00</h2>
|
||
<p>Les modifications restent locales à ce prototype.</p>
|
||
</div>
|
||
<button
|
||
class="expression-editor-close"
|
||
id="expression-editor-close"
|
||
type="button"
|
||
aria-label="Fermer"
|
||
>
|
||
×
|
||
</button>
|
||
</header>
|
||
<div class="expression-editor-body">
|
||
<aside class="expression-editor-preview">
|
||
<div id="expression-editor-preview"></div>
|
||
<strong id="expression-editor-number">Expression 00</strong>
|
||
<span>Aperçu sphérique en direct</span>
|
||
</aside>
|
||
<div class="expression-editor-fields" id="expression-editor-fields"></div>
|
||
</div>
|
||
<footer class="expression-editor-actions">
|
||
<button id="expression-editor-cancel" type="button">Annuler</button>
|
||
<button class="expression-editor-save" type="submit">Enregistrer</button>
|
||
</footer>
|
||
</form>
|
||
</dialog>
|
||
|
||
<script>
|
||
;(() => {
|
||
const $ = id => document.getElementById(id)
|
||
const radius = 120
|
||
const focalLength = 620
|
||
const samplesPerQuarterArc = 14
|
||
const gizmoCenter = [116, -116]
|
||
const gizmoRadius = 21
|
||
const gizmoOuterRadius = 27
|
||
const defaultValues = {
|
||
'head-y': 0,
|
||
'head-x': 0,
|
||
'head-z': 0,
|
||
'width-left': 20,
|
||
'width-right': 20,
|
||
'height-left': 50,
|
||
'height-right': 50,
|
||
spacing: 35,
|
||
latitude: -7,
|
||
'left-angle': 0,
|
||
'right-angle': 0,
|
||
perspective: 1,
|
||
}
|
||
const controls = Object.keys(defaultValues)
|
||
const presets = {
|
||
neutral: [0, 0],
|
||
angry: [24, -24],
|
||
sad: [-24, 24],
|
||
}
|
||
// Jeu initial de 25 presets, désormais éditables directement dans ce playground.
|
||
// [headX, headY, headZ, leftWidth, rightWidth, leftHeight, rightHeight,
|
||
// spacing, latitude, leftAngle, rightAngle]
|
||
const calibratedExpressionPresets = [
|
||
[7.3, 27.8, -16.1, 24.2, 27.6, 38.9, 40.7, 54.3, -20.5, 0, 0],
|
||
[-35.6, 0.7, -8.5, 29.4, 27.3, 49.5, 49.8, 57.7, -42, 0, 0],
|
||
[-36.2, 13.1, 15.5, 44.3, 51.3, 74.2, 76, 68.7, -40.7, 0, 0],
|
||
[15.6, -16.5, -11.3, 54, 51, 49.6, 48.5, 70.9, 30.1, 0, 0],
|
||
[3.4, 13, 8.9, 42.6, 44, 17.3, 16, 57.9, 4.9, 0, 0],
|
||
[-17.7, -1.4, -8.8, 29.5, 19.2, 51.6, 41.9, 56.3, 0, 0, 90],
|
||
[14.8, 14.5, 5.5, 22.9, 22.2, 32.4, 33.4, 50.9, 39.2, 0, 0],
|
||
[25.7, 16.5, -13.5, 48.5, 48.3, 33.5, 33, 53.3, 41.3, 61.3, -80],
|
||
[-22.8, -15.9, 6.2, 44.5, 43.9, 32.3, 24.6, 54.9, -42, -60.9, 69.2],
|
||
[-11.6, 8.3, -12.7, 42.5, 22.1, 41.8, 22.2, 61.7, 12.3, 0, 0],
|
||
[20.3, 7, 8.7, 30.2, 28.1, 48.8, 49.2, 56.8, 39.9, 0, 0],
|
||
[17.5, -15.2, -8.7, 51, 49.2, 75.3, 73.4, 70.2, 41.6, 0, 0],
|
||
[-10.4, 15.2, 11.8, 50.6, 51.6, 50, 50.7, 69.5, 16.7, 0, 0],
|
||
[-6, -7.7, -9.4, 43.6, 42.8, 15.5, 18.1, 57.9, 3.5, 0, 0],
|
||
[0.2, -3.1, 9, 29.6, 16.8, 51.5, 41.3, 56.4, -7.8, 0, 90],
|
||
[-16.2, 38.4, 2.4, 23.7, 26.2, 32.7, 34.6, 53.9, -41.1, 0, 0],
|
||
[3.5, -16.1, 15.8, 51, 48.5, 34.9, 33, 55.1, 41.9, 80, -62.2],
|
||
[-17.3, 11.2, -9.1, 24.2, 44.5, 44.5, 32.2, 55, -36.5, 18.5, 67.9],
|
||
[-0.7, 3.6, 12.2, 42.1, 22.2, 41.7, 22.1, 60.4, -9.1, 0, 0],
|
||
[-25.3, -12.4, -13.3, 30.5, 26.8, 49.9, 48.8, 56.2, -35.8, 0, 0],
|
||
[-41.1, 20.2, 18.8, 44.6, 53, 74.9, 77.8, 70.8, -40.6, 0, 0],
|
||
[-14.6, -12.5, -16.1, 51.4, 50.5, 50.1, 49.4, 69, -20, 0, 0],
|
||
[10, 2.7, 8.8, 42.9, 43.3, 16.4, 17.8, 57.9, 2.7, 0, 0],
|
||
[-17.8, 10, -6.3, 28.8, 17.3, 51.4, 42.7, 56.6, -9.8, 0, 90],
|
||
[-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0],
|
||
]
|
||
const expressionPresets = calibratedExpressionPresets.map(
|
||
([
|
||
headX,
|
||
headY,
|
||
headZ,
|
||
leftWidth,
|
||
rightWidth,
|
||
leftHeight,
|
||
rightHeight,
|
||
spacing,
|
||
latitude,
|
||
leftAngle,
|
||
rightAngle,
|
||
]) => ({
|
||
'head-x': headX,
|
||
'head-y': headY,
|
||
'head-z': headZ,
|
||
'width-left': leftWidth,
|
||
'width-right': rightWidth,
|
||
'height-left': leftHeight,
|
||
'height-right': rightHeight,
|
||
spacing,
|
||
latitude,
|
||
'left-angle': leftAngle,
|
||
'right-angle': rightAngle,
|
||
perspective: 1,
|
||
})
|
||
)
|
||
const stateGroups = {
|
||
'Cycle de vie': [
|
||
'sleeping',
|
||
'waking',
|
||
'idle',
|
||
'listening',
|
||
'thinking',
|
||
'searching',
|
||
'working',
|
||
],
|
||
Réactions: [
|
||
'excited',
|
||
'surprised',
|
||
'suspicious',
|
||
'angry',
|
||
'drowsy',
|
||
'happy',
|
||
'curious',
|
||
'confused',
|
||
'bored',
|
||
'proud',
|
||
'shy',
|
||
'sad',
|
||
'laughing',
|
||
'scared',
|
||
'playful',
|
||
'celebrate',
|
||
],
|
||
'Morphes agent': ['orbit', 'radar', 'progress'],
|
||
'Cycle produit': [
|
||
'spawning',
|
||
'humming',
|
||
'loading',
|
||
'dictating',
|
||
'writing',
|
||
'sending',
|
||
'receiving',
|
||
'uploading',
|
||
'notifying',
|
||
'alerting',
|
||
'dragging',
|
||
'bouncing',
|
||
'powering-down',
|
||
],
|
||
}
|
||
const statePools = {
|
||
sleeping: [13, 22, 4],
|
||
waking: [13],
|
||
idle: [0, 8],
|
||
listening: [10, 1, 19],
|
||
thinking: [8, 16, 14, 17, 5],
|
||
searching: [15, 9, 3, 20, 12, 18],
|
||
working: [7, 16, 11, 10],
|
||
excited: [2, 17, 21, 3, 11],
|
||
surprised: [3, 21],
|
||
suspicious: [14, 5, 23],
|
||
angry: [7, 16],
|
||
drowsy: [4, 22, 13],
|
||
happy: [2, 11, 17, 19],
|
||
curious: [3, 21, 0, 15],
|
||
confused: [14, 5, 8],
|
||
bored: [4, 22, 0],
|
||
proud: [15, 8, 2],
|
||
shy: [0, 24, 13],
|
||
sad: [4, 13, 22],
|
||
laughing: [2, 11, 17],
|
||
scared: [3, 21],
|
||
playful: [2, 17, 11, 8],
|
||
celebrate: [2, 8, 17],
|
||
orbit: [0, 8],
|
||
radar: [0, 8],
|
||
progress: [0, 8],
|
||
spawning: [3, 0],
|
||
humming: [0, 8],
|
||
loading: [0, 8],
|
||
dictating: [10, 1, 19],
|
||
sending: [0, 8],
|
||
receiving: [19, 0, 8],
|
||
uploading: [15, 9, 8],
|
||
writing: [15, 9],
|
||
notifying: [3, 21, 0],
|
||
alerting: [3, 21],
|
||
bouncing: [2, 17],
|
||
dragging: [3, 15, 0],
|
||
'powering-down': [13, 22],
|
||
}
|
||
const expressionCadences = {
|
||
sleeping: [6000, 10000],
|
||
waking: [800, 800],
|
||
idle: [9000, 16000],
|
||
listening: [2800, 5000],
|
||
thinking: [2000, 3600],
|
||
searching: [1000, 1800],
|
||
working: [1800, 3200],
|
||
excited: [1100, 2000],
|
||
surprised: [2500, 4000],
|
||
suspicious: [2600, 4500],
|
||
angry: [2200, 3800],
|
||
drowsy: [4000, 8000],
|
||
happy: [2500, 4500],
|
||
curious: [1800, 3200],
|
||
confused: [2200, 3800],
|
||
bored: [3500, 6000],
|
||
proud: [3500, 6000],
|
||
shy: [3000, 5500],
|
||
sad: [4000, 7000],
|
||
laughing: [1200, 2400],
|
||
scared: [900, 1800],
|
||
playful: [1500, 3000],
|
||
celebrate: [1400, 2600],
|
||
orbit: [4000, 8000],
|
||
radar: [4000, 8000],
|
||
progress: [4000, 8000],
|
||
spawning: [1200, 1200],
|
||
humming: [5000, 9000],
|
||
loading: [6000, 10000],
|
||
dictating: [4000, 8000],
|
||
sending: [4000, 8000],
|
||
receiving: [4000, 8000],
|
||
uploading: [4000, 8000],
|
||
writing: [4000, 8000],
|
||
notifying: [1500, 2600],
|
||
alerting: [2000, 3600],
|
||
bouncing: [3000, 6000],
|
||
dragging: [1600, 3000],
|
||
'powering-down': [6000, 9000],
|
||
}
|
||
const blinkCadences = {
|
||
sleeping: null,
|
||
waking: null,
|
||
idle: [6000, 14000],
|
||
listening: [3000, 7000],
|
||
thinking: [3500, 7000],
|
||
searching: [1600, 4000],
|
||
working: [2800, 5500],
|
||
excited: [2000, 4000],
|
||
surprised: [1800, 3500],
|
||
suspicious: [4500, 8000],
|
||
angry: [3500, 7000],
|
||
drowsy: null,
|
||
happy: [2500, 5000],
|
||
curious: [2500, 5500],
|
||
confused: [2800, 5500],
|
||
bored: [4000, 8000],
|
||
proud: [3500, 7000],
|
||
shy: [3000, 6000],
|
||
sad: [4000, 8000],
|
||
laughing: [2500, 5000],
|
||
scared: [1200, 3000],
|
||
playful: [2000, 4500],
|
||
celebrate: [2200, 4500],
|
||
orbit: null,
|
||
radar: null,
|
||
progress: null,
|
||
spawning: null,
|
||
humming: [4000, 8000],
|
||
loading: null,
|
||
dictating: null,
|
||
sending: null,
|
||
receiving: null,
|
||
uploading: null,
|
||
writing: null,
|
||
notifying: [2000, 4000],
|
||
alerting: null,
|
||
bouncing: null,
|
||
dragging: [2200, 4500],
|
||
'powering-down': null,
|
||
}
|
||
const stateNotes = {
|
||
sleeping: 'Yeux presque fermés, respiration lente et expression de sommeil.',
|
||
waking: 'Séquence courte de réveil avant retour vers une expression neutre.',
|
||
idle: 'Micro-mouvements lents, expressions 00 et 08, clignement rare.',
|
||
listening: 'Expressions 10, 01 et 19, regard stable et clignement attentif.',
|
||
thinking: 'Regard haut et latéral, expressions asymétriques et changements fréquents.',
|
||
searching: 'Balayage rapide et changements très fréquents.',
|
||
working: 'Rythme régulier et expressions concentrées.',
|
||
excited: 'Grandes expressions et transitions rapides.',
|
||
curious: 'Inclinaisons et forte asymétrie.',
|
||
}
|
||
let selectedSide = null
|
||
let interaction = null
|
||
let headOrientation = [1, 0, 0, 0]
|
||
const headControlValues = { x: 0, y: 0, z: 0 }
|
||
let viewRollDegrees = 0
|
||
let selectedRotationControl = null
|
||
const linkedDimensions = { width: true, height: true, scale: true }
|
||
let activeExpression = 0
|
||
let selectedState = 'idle'
|
||
let activeState = 'idle'
|
||
let expressionTimer = null
|
||
let blinkTimer = null
|
||
let blinkFrame = null
|
||
let expressionFrame = null
|
||
let expressionAnimation = null
|
||
let blinkFactor = 1
|
||
let editingExpressionIndex = null
|
||
let expressionDraft = null
|
||
|
||
const radians = degrees => (degrees * Math.PI) / 180
|
||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value))
|
||
|
||
function roundedRectangle(width, height) {
|
||
const halfWidth = width / 2
|
||
const halfHeight = height / 2
|
||
const cornerRadius = Math.min(halfHeight, halfWidth)
|
||
const points = []
|
||
|
||
const addLine = (start, end) => {
|
||
const length = Math.hypot(end[0] - start[0], end[1] - start[1])
|
||
const samples = Math.max(2, Math.ceil(length / 1.5))
|
||
for (let index = 0; index < samples; index += 1) {
|
||
const progress = index / samples
|
||
points.push([
|
||
start[0] + (end[0] - start[0]) * progress,
|
||
start[1] + (end[1] - start[1]) * progress,
|
||
])
|
||
}
|
||
}
|
||
|
||
const addArc = (centerX, centerY, startAngle) => {
|
||
for (let index = 0; index < samplesPerQuarterArc; index += 1) {
|
||
const angle = startAngle + (index / samplesPerQuarterArc) * (Math.PI / 2)
|
||
points.push([
|
||
centerX + Math.cos(angle) * cornerRadius,
|
||
centerY + Math.sin(angle) * cornerRadius,
|
||
])
|
||
}
|
||
}
|
||
|
||
addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight])
|
||
addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2)
|
||
addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius])
|
||
addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0)
|
||
addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight])
|
||
addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2)
|
||
addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius])
|
||
addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI)
|
||
return points
|
||
}
|
||
|
||
function rotateX([x, y, z], angle) {
|
||
const cosine = Math.cos(angle)
|
||
const sine = Math.sin(angle)
|
||
return [x, y * cosine - z * sine, y * sine + z * cosine]
|
||
}
|
||
|
||
function rotateY([x, y, z], angle) {
|
||
const cosine = Math.cos(angle)
|
||
const sine = Math.sin(angle)
|
||
return [x * cosine + z * sine, y, -x * sine + z * cosine]
|
||
}
|
||
|
||
function rotateZ([x, y, z], angle) {
|
||
const cosine = Math.cos(angle)
|
||
const sine = Math.sin(angle)
|
||
return [x * cosine - y * sine, x * sine + y * cosine, z]
|
||
}
|
||
|
||
function normalizeQuaternion([w, x, y, z]) {
|
||
const length = Math.hypot(w, x, y, z) || 1
|
||
return [w / length, x / length, y / length, z / length]
|
||
}
|
||
|
||
function multiplyQuaternions([aw, ax, ay, az], [bw, bx, by, bz]) {
|
||
return normalizeQuaternion([
|
||
aw * bw - ax * bx - ay * by - az * bz,
|
||
aw * bx + ax * bw + ay * bz - az * by,
|
||
aw * by - ax * bz + ay * bw + az * bx,
|
||
aw * bz + ax * by - ay * bx + az * bw,
|
||
])
|
||
}
|
||
|
||
function quaternionFromAxisAngle([x, y, z], angle) {
|
||
const halfAngle = angle / 2
|
||
const sine = Math.sin(halfAngle)
|
||
return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine])
|
||
}
|
||
|
||
function quaternionFromEuler(x, y, z) {
|
||
const xRotation = quaternionFromAxisAngle([1, 0, 0], x)
|
||
const yRotation = quaternionFromAxisAngle([0, 1, 0], y)
|
||
const zRotation = quaternionFromAxisAngle([0, 0, 1], z)
|
||
return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation)
|
||
}
|
||
|
||
function quaternionToEuler([w, x, y, z]) {
|
||
const matrix00 = 1 - 2 * (y * y + z * z)
|
||
const matrix01 = 2 * (x * y - z * w)
|
||
const matrix10 = 2 * (x * y + z * w)
|
||
const matrix11 = 1 - 2 * (x * x + z * z)
|
||
const matrix20 = 2 * (x * z - y * w)
|
||
const matrix21 = 2 * (y * z + x * w)
|
||
const matrix22 = 1 - 2 * (x * x + y * y)
|
||
const headX = Math.asin(clamp(matrix21, -1, 1))
|
||
const cosineX = Math.cos(headX)
|
||
|
||
if (Math.abs(cosineX) < 0.00001) {
|
||
return [headX, 0, Math.atan2(matrix10, matrix00)]
|
||
}
|
||
return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)]
|
||
}
|
||
|
||
function nearestEquivalentAngle(angle, current) {
|
||
let result = angle
|
||
while (result - current > 180) result -= 360
|
||
while (result - current < -180) result += 360
|
||
return clamp(result, -365, 365)
|
||
}
|
||
|
||
function syncOrientationFromControls() {
|
||
headControlValues.x = Number($('head-x').value)
|
||
headControlValues.y = Number($('head-y').value)
|
||
headControlValues.z = Number($('head-z').value)
|
||
headOrientation = quaternionFromEuler(
|
||
radians(headControlValues.x),
|
||
radians(headControlValues.y),
|
||
radians(headControlValues.z)
|
||
)
|
||
}
|
||
|
||
function syncControlsFromOrientation() {
|
||
const [headX, headY, headZ] = quaternionToEuler(headOrientation).map(
|
||
angle => (angle * 180) / Math.PI
|
||
)
|
||
$('head-x').value = nearestEquivalentAngle(headX, Number($('head-x').value))
|
||
$('head-y').value = nearestEquivalentAngle(headY, Number($('head-y').value))
|
||
$('head-z').value = nearestEquivalentAngle(headZ, Number($('head-z').value))
|
||
headControlValues.x = Number($('head-x').value)
|
||
headControlValues.y = Number($('head-y').value)
|
||
headControlValues.z = Number($('head-z').value)
|
||
}
|
||
|
||
function applyAxisControlValue(axis, nextValue) {
|
||
const previousValue = headControlValues[axis]
|
||
const worldAxis = rotateWithQuaternion(headOrientation, axisVector(axis))
|
||
headOrientation = multiplyQuaternions(
|
||
quaternionFromAxisAngle(worldAxis, radians(nextValue - previousValue)),
|
||
headOrientation
|
||
)
|
||
headControlValues[axis] = nextValue
|
||
}
|
||
|
||
function quaternionFromVectors(from, to) {
|
||
const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2]
|
||
const cross = [
|
||
from[1] * to[2] - from[2] * to[1],
|
||
from[2] * to[0] - from[0] * to[2],
|
||
from[0] * to[1] - from[1] * to[0],
|
||
]
|
||
return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]])
|
||
}
|
||
|
||
function rotateWithQuaternion([w, x, y, z], [px, py, pz]) {
|
||
const tx = 2 * (y * pz - z * py)
|
||
const ty = 2 * (z * px - x * pz)
|
||
const tz = 2 * (x * py - y * px)
|
||
return [
|
||
px + w * tx + (y * tz - z * ty),
|
||
py + w * ty + (z * tx - x * tz),
|
||
pz + w * tz + (x * ty - y * tx),
|
||
]
|
||
}
|
||
|
||
function rotateHead(point) {
|
||
return rotateWithQuaternion(headOrientation, point)
|
||
}
|
||
|
||
function spherePoint(longitude, latitude) {
|
||
const latitudeCosine = Math.cos(latitude)
|
||
return [
|
||
radius * latitudeCosine * Math.sin(longitude),
|
||
radius * Math.sin(latitude),
|
||
radius * latitudeCosine * Math.cos(longitude),
|
||
]
|
||
}
|
||
|
||
function project(point) {
|
||
const strength = animatedControlValue('perspective')
|
||
const naturalPerspective = focalLength / (focalLength - point[2])
|
||
const perspectiveScale = 1 + (naturalPerspective - 1) * strength
|
||
return [point[0] * perspectiveScale, point[1] * perspectiveScale, point[2]]
|
||
}
|
||
|
||
function projectedHeadRadiusFor(strength) {
|
||
let lowerAngle = 0
|
||
let upperAngle = Math.PI / 2
|
||
const projectedRadiusAt = angle => {
|
||
const depth = radius * Math.cos(angle)
|
||
const naturalPerspective = focalLength / (focalLength - depth)
|
||
const perspectiveScale = 1 + (naturalPerspective - 1) * strength
|
||
return radius * Math.sin(angle) * perspectiveScale
|
||
}
|
||
|
||
// La projection interpolée du playground n’est une caméra physique exacte
|
||
// qu’à strength = 1. Cette recherche du maximum reste donc correcte sur 0…3.
|
||
for (let iteration = 0; iteration < 28; iteration += 1) {
|
||
const first = lowerAngle + (upperAngle - lowerAngle) / 3
|
||
const second = upperAngle - (upperAngle - lowerAngle) / 3
|
||
if (projectedRadiusAt(first) < projectedRadiusAt(second)) {
|
||
lowerAngle = first
|
||
} else {
|
||
upperAngle = second
|
||
}
|
||
}
|
||
return projectedRadiusAt((lowerAngle + upperAngle) / 2)
|
||
}
|
||
|
||
function projectedHeadRadius() {
|
||
return projectedHeadRadiusFor(animatedControlValue('perspective'))
|
||
}
|
||
|
||
function renderHeadSilhouette() {
|
||
const apparentRadius = projectedHeadRadius()
|
||
$('avatar').querySelector('.head').setAttribute('r', apparentRadius.toFixed(3))
|
||
$('head-clip-circle').setAttribute('r', apparentRadius.toFixed(3))
|
||
$('head-radius-output').textContent = `silhouette · rayon ${apparentRadius.toFixed(2)} u`
|
||
}
|
||
|
||
function eyeSettings(side) {
|
||
const suffix = side < 0 ? 'left' : 'right'
|
||
const width = animatedControlValue(`width-${suffix}`)
|
||
const restingHeight = animatedControlValue(`height-${suffix}`)
|
||
const height = 5 + (restingHeight - 5) * blinkFactor
|
||
const spacing = animatedControlValue('spacing')
|
||
const eyeCenterX = (side * spacing) / 2
|
||
const eyeCenterY = animatedControlValue('latitude')
|
||
const angle = radians(animatedControlValue(side < 0 ? 'left-angle' : 'right-angle'))
|
||
return { width, height, eyeCenterX, eyeCenterY, angle }
|
||
}
|
||
|
||
function dimensionId(dimension, side) {
|
||
return `${dimension}-${side < 0 ? 'left' : 'right'}`
|
||
}
|
||
|
||
function setEyeDimension(dimension, side, value) {
|
||
$(dimensionId(dimension, side)).value = value
|
||
if (linkedDimensions[dimension]) {
|
||
$(dimensionId(dimension, -side)).value = value
|
||
}
|
||
}
|
||
|
||
function eyeSize(side) {
|
||
return Math.sqrt(
|
||
Number($(dimensionId('width', side)).value) *
|
||
Number($(dimensionId('height', side)).value)
|
||
)
|
||
}
|
||
|
||
function resizeEye(side, factor, sourceDimensions = null) {
|
||
const suffix = side < 0 ? 'left' : 'right'
|
||
const width = sourceDimensions?.[`width-${suffix}`] ?? Number($(`width-${suffix}`).value)
|
||
const height =
|
||
sourceDimensions?.[`height-${suffix}`] ?? Number($(`height-${suffix}`).value)
|
||
$(`width-${suffix}`).value = clamp(width * factor, 12, 100)
|
||
$(`height-${suffix}`).value = clamp(height * factor, 5, 100)
|
||
}
|
||
|
||
function resizeEyesFromSizeControl(side, targetSize) {
|
||
const currentSize = eyeSize(side)
|
||
if (!currentSize) return
|
||
const factor = targetSize / currentSize
|
||
resizeEye(side, factor)
|
||
if (linkedDimensions.scale) resizeEye(-side, factor)
|
||
}
|
||
|
||
const expressionControlIds = [
|
||
'head-x',
|
||
'head-y',
|
||
'head-z',
|
||
'width-left',
|
||
'width-right',
|
||
'height-left',
|
||
'height-right',
|
||
'spacing',
|
||
'latitude',
|
||
'left-angle',
|
||
'right-angle',
|
||
'perspective',
|
||
]
|
||
|
||
function expressionTargets(index) {
|
||
return { ...expressionPresets[index] }
|
||
}
|
||
|
||
const expressionEditorGroups = [
|
||
{
|
||
title: 'Rotation de la tête',
|
||
fields: [
|
||
['head-x', 'Rotation X'],
|
||
['head-y', 'Rotation Y'],
|
||
['head-z', 'Rotation Z'],
|
||
],
|
||
},
|
||
{
|
||
title: 'Œil gauche',
|
||
fields: [
|
||
['width-left', 'Largeur'],
|
||
['height-left', 'Hauteur'],
|
||
['left-angle', 'Rotation locale'],
|
||
],
|
||
},
|
||
{
|
||
title: 'Œil droit',
|
||
fields: [
|
||
['width-right', 'Largeur'],
|
||
['height-right', 'Hauteur'],
|
||
['right-angle', 'Rotation locale'],
|
||
],
|
||
},
|
||
{
|
||
title: 'Placement et caméra',
|
||
fields: [
|
||
['spacing', 'Espacement'],
|
||
['latitude', 'Position verticale'],
|
||
['perspective', 'Perspective'],
|
||
],
|
||
},
|
||
]
|
||
|
||
function previewEyePoints(targets, side) {
|
||
const suffix = side < 0 ? 'left' : 'right'
|
||
const width = targets[`width-${suffix}`]
|
||
const height = targets[`height-${suffix}`]
|
||
const eyeCenterX = (side * targets.spacing) / 2
|
||
const angle = radians(targets[side < 0 ? 'left-angle' : 'right-angle'])
|
||
const orientation = quaternionFromEuler(
|
||
radians(targets['head-x']),
|
||
radians(targets['head-y']),
|
||
radians(targets['head-z'])
|
||
)
|
||
return roundedRectangle(width, height).map(([localX, localY]) => {
|
||
const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
|
||
const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
|
||
const surface = spherePoint(
|
||
(eyeCenterX + rotatedX) / radius,
|
||
(targets.latitude + rotatedY) / radius
|
||
)
|
||
const rotated = rotateWithQuaternion(orientation, surface)
|
||
const naturalPerspective = focalLength / (focalLength - rotated[2])
|
||
const perspectiveScale = 1 + (naturalPerspective - 1) * Number(targets.perspective)
|
||
return [rotated[0] * perspectiveScale, rotated[1] * perspectiveScale, rotated[2]]
|
||
})
|
||
}
|
||
|
||
function expressionPreviewSvg(targets, key) {
|
||
const left = previewEyePoints(targets, -1)
|
||
const right = previewEyePoints(targets, 1)
|
||
const clipId = `expression-preview-${key}`
|
||
const headRadius = projectedHeadRadiusFor(Number(targets.perspective))
|
||
const eyePath = points =>
|
||
`<path d="${path(points)}" fill="#111316" opacity="${
|
||
averageDepth(points) > 0 ? 1 : 0
|
||
}" />`
|
||
return `<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
||
<defs><clipPath id="${clipId}"><circle cx="0" cy="0" r="${headRadius.toFixed(
|
||
3
|
||
)}" /></clipPath></defs>
|
||
<circle cx="0" cy="0" r="${headRadius.toFixed(3)}" fill="#5b7fe5" />
|
||
<g clip-path="url(#${clipId})">${eyePath(left)}${eyePath(right)}</g>
|
||
</svg>`
|
||
}
|
||
|
||
function renderExpressionEditorPreview() {
|
||
if (!expressionDraft) return
|
||
$('expression-editor-preview').innerHTML = expressionPreviewSvg(expressionDraft, 'editor')
|
||
}
|
||
|
||
function buildExpressionEditorFields() {
|
||
$('expression-editor-fields').replaceChildren()
|
||
expressionEditorGroups.forEach(group => {
|
||
const section = document.createElement('section')
|
||
const title = document.createElement('h3')
|
||
section.className = 'expression-editor-group'
|
||
title.textContent = group.title
|
||
section.appendChild(title)
|
||
group.fields.forEach(([id, label]) => {
|
||
const source = $(id)
|
||
const row = document.createElement('label')
|
||
const name = document.createElement('span')
|
||
const range = document.createElement('input')
|
||
const number = document.createElement('input')
|
||
row.className = 'expression-field'
|
||
name.textContent = label
|
||
range.type = 'range'
|
||
range.min = source.min
|
||
range.max = source.max
|
||
range.step = source.step
|
||
range.dataset.expressionField = id
|
||
number.type = 'number'
|
||
number.min = source.min
|
||
number.max = source.max
|
||
number.step = source.step
|
||
number.dataset.expressionNumber = id
|
||
const updateDraft = value => {
|
||
const parsed = Number(value)
|
||
if (!Number.isFinite(parsed)) return
|
||
const next = clamp(parsed, Number(source.min), Number(source.max))
|
||
expressionDraft[id] = next
|
||
range.value = next
|
||
number.value = next
|
||
renderExpressionEditorPreview()
|
||
}
|
||
range.addEventListener('input', event => updateDraft(event.currentTarget.value))
|
||
number.addEventListener('input', event => updateDraft(event.currentTarget.value))
|
||
row.append(name, range, number)
|
||
section.appendChild(row)
|
||
})
|
||
$('expression-editor-fields').appendChild(section)
|
||
})
|
||
}
|
||
|
||
function openExpressionEditor(index = null) {
|
||
cancelExpressionAnimation()
|
||
editingExpressionIndex = index
|
||
expressionDraft =
|
||
index === null
|
||
? Object.fromEntries(expressionControlIds.map(id => [id, animatedControlValue(id)]))
|
||
: expressionTargets(index)
|
||
const displayIndex = index ?? expressionPresets.length
|
||
const number = String(displayIndex).padStart(2, '0')
|
||
$('expression-editor-title').textContent =
|
||
index === null ? 'Créer une nouvelle expression' : `Modifier l’expression ${number}`
|
||
$('expression-editor-number').textContent =
|
||
index === null ? `Nouvelle expression ${number}` : `Expression ${number}`
|
||
expressionControlIds.forEach(id => {
|
||
const value = expressionDraft[id]
|
||
document.querySelector(`[data-expression-field="${id}"]`).value = value
|
||
document.querySelector(`[data-expression-number="${id}"]`).value = value
|
||
})
|
||
renderExpressionEditorPreview()
|
||
$('expression-editor').showModal()
|
||
}
|
||
|
||
function closeExpressionEditor() {
|
||
$('expression-editor').close()
|
||
editingExpressionIndex = null
|
||
expressionDraft = null
|
||
}
|
||
|
||
function cancelExpressionAnimation() {
|
||
if (expressionFrame !== null) cancelAnimationFrame(expressionFrame)
|
||
expressionFrame = null
|
||
expressionAnimation = null
|
||
}
|
||
|
||
function animatedControlValue(id) {
|
||
return expressionAnimation?.values?.[id] ?? Number($(id).value)
|
||
}
|
||
|
||
function lerp(start, end, progress) {
|
||
return start + (end - start) * progress
|
||
}
|
||
|
||
function slerpQuaternion(start, end, progress) {
|
||
let target = end
|
||
let dot = start.reduce((total, value, index) => total + value * target[index], 0)
|
||
if (dot < 0) {
|
||
target = target.map(value => -value)
|
||
dot = -dot
|
||
}
|
||
if (dot > 0.9995) {
|
||
return normalizeQuaternion(
|
||
start.map((value, index) => lerp(value, target[index], progress))
|
||
)
|
||
}
|
||
const angle = Math.acos(clamp(dot, -1, 1))
|
||
const sine = Math.sin(angle)
|
||
const startWeight = Math.sin((1 - progress) * angle) / sine
|
||
const targetWeight = Math.sin(progress * angle) / sine
|
||
return normalizeQuaternion(
|
||
start.map((value, index) => value * startWeight + target[index] * targetWeight)
|
||
)
|
||
}
|
||
|
||
function selectExpression(index, immediate = false) {
|
||
const startValues = Object.fromEntries(
|
||
expressionControlIds.map(id => [id, animatedControlValue(id)])
|
||
)
|
||
const startOrientation = [...headOrientation]
|
||
cancelExpressionAnimation()
|
||
selectedSide = null
|
||
selectedRotationControl = null
|
||
activeExpression = index
|
||
$('active-expression').textContent = String(index).padStart(2, '0')
|
||
document.querySelectorAll('[data-expression]').forEach(button => {
|
||
button.setAttribute('aria-pressed', String(Number(button.dataset.expression) === index))
|
||
})
|
||
;['width', 'height', 'scale'].forEach(dimension => {
|
||
linkedDimensions[dimension] = false
|
||
document
|
||
.querySelector(`[data-link="${dimension}"]`)
|
||
.setAttribute('aria-pressed', 'false')
|
||
})
|
||
const targets = expressionTargets(index)
|
||
if (immediate) {
|
||
Object.entries(targets).forEach(([id, value]) => ($(id).value = value))
|
||
syncOrientationFromControls()
|
||
render()
|
||
return
|
||
}
|
||
expressionAnimation = {
|
||
startedAt: performance.now(),
|
||
values: { ...startValues },
|
||
startValues,
|
||
targets,
|
||
startOrientation,
|
||
targetOrientation: quaternionFromEuler(
|
||
radians(targets['head-x']),
|
||
radians(targets['head-y']),
|
||
radians(targets['head-z'])
|
||
),
|
||
}
|
||
const animateExpression = now => {
|
||
if (!expressionAnimation) return
|
||
const frequency = Number($('spring-speed').value)
|
||
const elapsed = Math.max(0, (now - expressionAnimation.startedAt) / 1000)
|
||
const progress = 1 - (1 + frequency * elapsed) * Math.exp(-frequency * elapsed)
|
||
expressionControlIds.forEach(id => {
|
||
const next = lerp(
|
||
expressionAnimation.startValues[id],
|
||
expressionAnimation.targets[id],
|
||
progress
|
||
)
|
||
expressionAnimation.values[id] = next
|
||
$(id).value = next
|
||
})
|
||
headControlValues.x = expressionAnimation.values['head-x']
|
||
headControlValues.y = expressionAnimation.values['head-y']
|
||
headControlValues.z = expressionAnimation.values['head-z']
|
||
headOrientation = slerpQuaternion(
|
||
expressionAnimation.startOrientation,
|
||
expressionAnimation.targetOrientation,
|
||
progress
|
||
)
|
||
render()
|
||
if (progress < 0.9995) {
|
||
expressionFrame = requestAnimationFrame(animateExpression)
|
||
} else {
|
||
Object.entries(expressionAnimation.targets).forEach(
|
||
([id, value]) => ($(id).value = value)
|
||
)
|
||
headControlValues.x = expressionAnimation.targets['head-x']
|
||
headControlValues.y = expressionAnimation.targets['head-y']
|
||
headControlValues.z = expressionAnimation.targets['head-z']
|
||
headOrientation = expressionAnimation.targetOrientation
|
||
expressionFrame = null
|
||
expressionAnimation = null
|
||
render()
|
||
}
|
||
}
|
||
expressionFrame = requestAnimationFrame(animateExpression)
|
||
}
|
||
|
||
function blink() {
|
||
if (blinkFrame !== null) cancelAnimationFrame(blinkFrame)
|
||
const startedAt = performance.now()
|
||
const duration = Number($('blink-duration').value)
|
||
const animateBlink = now => {
|
||
const progress = (now - startedAt) / duration
|
||
if (progress >= 1) {
|
||
blinkFactor = 1
|
||
blinkFrame = null
|
||
render()
|
||
return
|
||
}
|
||
blinkFactor = progress < 0.42 ? 1 - progress / 0.42 : (progress - 0.42) / 0.58
|
||
render()
|
||
blinkFrame = requestAnimationFrame(animateBlink)
|
||
}
|
||
blinkFrame = requestAnimationFrame(animateBlink)
|
||
}
|
||
|
||
function projectEyePoint(side, localX, localY) {
|
||
const { eyeCenterX, eyeCenterY, angle } = eyeSettings(side)
|
||
const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
|
||
const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
|
||
const longitude = (eyeCenterX + rotatedX) / radius
|
||
const latitude = (eyeCenterY + rotatedY) / radius
|
||
const surfacePoint = spherePoint(longitude, latitude)
|
||
return project(rotateHead(surfacePoint))
|
||
}
|
||
|
||
function projectedEye(side) {
|
||
const { width, height } = eyeSettings(side)
|
||
return roundedRectangle(width, height).map(([localX, localY]) =>
|
||
projectEyePoint(side, localX, localY)
|
||
)
|
||
}
|
||
|
||
function path(points, close = true) {
|
||
if (!points.length) return ''
|
||
return (
|
||
`M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}` +
|
||
points
|
||
.slice(1)
|
||
.map(point => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`)
|
||
.join('') +
|
||
(close ? 'Z' : '')
|
||
)
|
||
}
|
||
|
||
function averageDepth(points) {
|
||
return points.reduce((total, point) => total + point[2], 0) / points.length
|
||
}
|
||
|
||
function drawPoints(left, right) {
|
||
$('points').replaceChildren()
|
||
if (!$('show-points').checked) return
|
||
;[left, right].flat().forEach(([x, y, z]) => {
|
||
if (z <= 0) return
|
||
const point = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
||
point.setAttribute('class', 'point')
|
||
point.setAttribute('cx', x)
|
||
point.setAttribute('cy', y)
|
||
point.setAttribute('r', '1.4')
|
||
$('points').append(point)
|
||
})
|
||
}
|
||
|
||
function projectedLine(points) {
|
||
return points.map(([longitude, latitude]) =>
|
||
project(rotateHead(spherePoint(longitude, latitude)))
|
||
)
|
||
}
|
||
|
||
function drawWireframe() {
|
||
$('wires').replaceChildren()
|
||
$('equator').setAttribute('d', '')
|
||
if (!$('show-wire').checked) return
|
||
const appendLine = points => {
|
||
const visibleSegments = []
|
||
let segment = []
|
||
points.forEach(point => {
|
||
if (point[2] > 0) segment.push(point)
|
||
else if (segment.length) {
|
||
visibleSegments.push(segment)
|
||
segment = []
|
||
}
|
||
})
|
||
if (segment.length) visibleSegments.push(segment)
|
||
visibleSegments.forEach(pointsInSegment => {
|
||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||
line.setAttribute('class', 'wire')
|
||
line.setAttribute('d', path(pointsInSegment, false))
|
||
$('wires').append(line)
|
||
})
|
||
}
|
||
for (let latitude = -60; latitude <= 60; latitude += 30) {
|
||
appendLine(
|
||
projectedLine(
|
||
Array.from({ length: 73 }, (_, index) => [
|
||
radians(-180 + index * 5),
|
||
radians(latitude),
|
||
])
|
||
)
|
||
)
|
||
}
|
||
for (let longitude = -150; longitude <= 180; longitude += 30) {
|
||
appendLine(
|
||
projectedLine(
|
||
Array.from({ length: 37 }, (_, index) => [
|
||
radians(longitude),
|
||
radians(-90 + index * 5),
|
||
])
|
||
)
|
||
)
|
||
}
|
||
const equator = projectedLine(
|
||
Array.from({ length: 73 }, (_, index) => [radians(-180 + index * 5), 0])
|
||
).filter(point => point[2] > 0)
|
||
$('equator').setAttribute('d', path(equator, false))
|
||
}
|
||
|
||
function projectFacePoint(faceX, faceY) {
|
||
return project(rotateHead(spherePoint(faceX / radius, faceY / radius)))
|
||
}
|
||
|
||
function setCirclePosition(id, point) {
|
||
$(id).setAttribute('cx', point[0])
|
||
$(id).setAttribute('cy', point[1])
|
||
}
|
||
|
||
function setRectPosition(id, point) {
|
||
$(id).setAttribute('x', point[0] - 5)
|
||
$(id).setAttribute('y', point[1] - 5)
|
||
}
|
||
|
||
function setLabelPosition(id, point) {
|
||
$(id).setAttribute('x', point[0])
|
||
$(id).setAttribute('y', point[1] + 2.5)
|
||
}
|
||
|
||
function renderEditor(left, right) {
|
||
if (selectedSide === null) {
|
||
$('editor').setAttribute('visibility', 'hidden')
|
||
return
|
||
}
|
||
|
||
const selectedPoints = selectedSide < 0 ? left : right
|
||
if (averageDepth(selectedPoints) <= 0) {
|
||
$('editor').setAttribute('visibility', 'hidden')
|
||
return
|
||
}
|
||
|
||
const { width, height, eyeCenterY } = eyeSettings(selectedSide)
|
||
const center = projectEyePoint(selectedSide, 0, 0)
|
||
const widthHandle = projectEyePoint(selectedSide, width / 2 + 9, 0)
|
||
const heightHandle = projectEyePoint(selectedSide, 0, -height / 2 - 9)
|
||
const rotateHandle = projectEyePoint(selectedSide, 0, -height / 2 - 30)
|
||
const scaleHandle = projectEyePoint(selectedSide, width / 2 + 11, height / 2 + 11)
|
||
const leftCenter = projectEyePoint(-1, 0, 0)
|
||
const rightCenter = projectEyePoint(1, 0, 0)
|
||
const spacingHandle = projectFacePoint(0, eyeCenterY + height / 2 + 34)
|
||
const spacingMiddle = [
|
||
(leftCenter[0] + rightCenter[0]) / 2,
|
||
(leftCenter[1] + rightCenter[1]) / 2,
|
||
]
|
||
|
||
$('selection-outline').setAttribute('d', path(selectedPoints))
|
||
$('width-guide').setAttribute(
|
||
'd',
|
||
`M${center[0]} ${center[1]}L${widthHandle[0]} ${widthHandle[1]}`
|
||
)
|
||
$('height-guide').setAttribute(
|
||
'd',
|
||
`M${center[0]} ${center[1]}L${heightHandle[0]} ${heightHandle[1]}`
|
||
)
|
||
$('rotation-guide').setAttribute(
|
||
'd',
|
||
`M${heightHandle[0]} ${heightHandle[1]}L${rotateHandle[0]} ${rotateHandle[1]}`
|
||
)
|
||
$('spacing-guide').setAttribute(
|
||
'd',
|
||
`M${leftCenter[0]} ${leftCenter[1]}L${rightCenter[0]} ${rightCenter[1]}M${spacingMiddle[0]} ${spacingMiddle[1]}L${spacingHandle[0]} ${spacingHandle[1]}`
|
||
)
|
||
|
||
setCirclePosition('width-handle', widthHandle)
|
||
setCirclePosition('height-handle', heightHandle)
|
||
setCirclePosition('rotate-handle', rotateHandle)
|
||
setRectPosition('scale-handle', scaleHandle)
|
||
setRectPosition('spacing-handle', spacingHandle)
|
||
setLabelPosition('width-label', widthHandle)
|
||
setLabelPosition('height-label', heightHandle)
|
||
setLabelPosition('rotate-label', rotateHandle)
|
||
setLabelPosition('scale-label', scaleHandle)
|
||
setLabelPosition('spacing-label', spacingHandle)
|
||
$('editor').setAttribute('visibility', 'visible')
|
||
}
|
||
|
||
function rotationRing(axis) {
|
||
return Array.from({ length: 97 }, (_, index) => {
|
||
const angle = (index / 96) * Math.PI * 2
|
||
const cosine = Math.cos(angle)
|
||
const sine = Math.sin(angle)
|
||
const point =
|
||
axis === 'x'
|
||
? [0, cosine, sine]
|
||
: axis === 'y'
|
||
? [cosine, 0, sine]
|
||
: [cosine, sine, 0]
|
||
const rotated = rotateWithQuaternion(headOrientation, point)
|
||
return [
|
||
gizmoCenter[0] + rotated[0] * gizmoRadius,
|
||
gizmoCenter[1] + rotated[1] * gizmoRadius,
|
||
rotated[2],
|
||
]
|
||
})
|
||
}
|
||
|
||
function axisVector(axis) {
|
||
return axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]
|
||
}
|
||
|
||
function dotProduct(first, second) {
|
||
return first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
|
||
}
|
||
|
||
function cameraRollWeights(orientation) {
|
||
const cameraAxis = [0, 0, 1]
|
||
return Object.fromEntries(
|
||
['x', 'y', 'z'].map(axis => [
|
||
axis,
|
||
dotProduct(cameraAxis, rotateWithQuaternion(orientation, axisVector(axis))),
|
||
])
|
||
)
|
||
}
|
||
|
||
function ringTangentAtPoint(axis, point) {
|
||
const ring = rotationRing(axis)
|
||
let closestIndex = 0
|
||
let closestDistance = Number.POSITIVE_INFINITY
|
||
ring.slice(0, -1).forEach((ringPoint, index) => {
|
||
const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1])
|
||
if (distance < closestDistance) {
|
||
closestDistance = distance
|
||
closestIndex = index
|
||
}
|
||
})
|
||
const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)]
|
||
const next = ring[(closestIndex + 1) % (ring.length - 1)]
|
||
return unitVector(previous, next)
|
||
}
|
||
|
||
function renderRotationGizmo() {
|
||
$('axis-x-ring').setAttribute('d', path(rotationRing('x')))
|
||
$('axis-y-ring').setAttribute('d', path(rotationRing('y')))
|
||
$('axis-z-ring').setAttribute('d', path(rotationRing('z')))
|
||
const angle = radians(viewRollDegrees) - Math.PI / 2
|
||
const point = [
|
||
gizmoCenter[0] + Math.cos(angle) * gizmoOuterRadius,
|
||
gizmoCenter[1] + Math.sin(angle) * gizmoOuterRadius,
|
||
]
|
||
setCirclePosition('view-roll-handle', point)
|
||
;['x', 'y', 'z'].forEach(axis => {
|
||
$(`axis-${axis}-ring`).classList.toggle('is-active', selectedRotationControl === axis)
|
||
})
|
||
$('view-roll-ring').classList.toggle('is-active', selectedRotationControl === 'view')
|
||
$('avatar')
|
||
.querySelector('.head')
|
||
.classList.toggle('is-active', selectedRotationControl === 'arcball')
|
||
}
|
||
|
||
function updateOutputs() {
|
||
const units = id => `${animatedControlValue(id).toFixed(1)} u`
|
||
$('head-y-output').textContent = `${animatedControlValue('head-y').toFixed(1)}°`
|
||
$('head-x-output').textContent = `${animatedControlValue('head-x').toFixed(1)}°`
|
||
$('head-z-output').textContent = `${animatedControlValue('head-z').toFixed(1)}°`
|
||
;['left', 'right'].forEach(side => {
|
||
$(`width-${side}-output`).textContent = units(`width-${side}`)
|
||
$(`height-${side}-output`).textContent = units(`height-${side}`)
|
||
const sideValue = side === 'left' ? -1 : 1
|
||
const size = eyeSize(sideValue)
|
||
$(`scale-${side}`).value = size
|
||
$(`scale-${side}-output`).textContent = `${size.toFixed(1)} u`
|
||
})
|
||
$('spacing-output').textContent = units('spacing')
|
||
$('latitude-output').textContent = units('latitude')
|
||
$('left-angle-output').textContent = `${animatedControlValue('left-angle').toFixed(1)}°`
|
||
$('right-angle-output').textContent = `${animatedControlValue('right-angle').toFixed(1)}°`
|
||
$('perspective-output').textContent = `${animatedControlValue('perspective').toFixed(2)}×`
|
||
$('arcball-output').textContent = `orientation · q(${headOrientation
|
||
.map(value => value.toFixed(3))
|
||
.join(', ')})`
|
||
}
|
||
|
||
function render() {
|
||
renderHeadSilhouette()
|
||
const left = projectedEye(-1)
|
||
const right = projectedEye(1)
|
||
const leftVisible = averageDepth(left) > 0
|
||
const rightVisible = averageDepth(right) > 0
|
||
$('left-eye').setAttribute('d', path(left))
|
||
$('right-eye').setAttribute('d', path(right))
|
||
$('left-eye').style.opacity = leftVisible ? 1 : 0
|
||
$('right-eye').style.opacity = rightVisible ? 1 : 0
|
||
$('visible-left').textContent = `gauche · ${leftVisible ? 'visible' : 'masqué'}`
|
||
$('visible-right').textContent = `droite · ${rightVisible ? 'visible' : 'masqué'}`
|
||
drawPoints(left, right)
|
||
drawWireframe()
|
||
renderEditor(left, right)
|
||
renderRotationGizmo()
|
||
updateOutputs()
|
||
}
|
||
|
||
function applyPreset(name) {
|
||
const [left, right] = presets[name]
|
||
$('left-angle').value = left
|
||
$('right-angle').value = right
|
||
document.querySelectorAll('[data-preset]').forEach(button => {
|
||
button.setAttribute('aria-pressed', String(button.dataset.preset === name))
|
||
})
|
||
render()
|
||
}
|
||
|
||
const formatCadence = cadence =>
|
||
cadence
|
||
? `${(cadence[0] / 1000).toFixed(cadence[0] % 1000 ? 1 : 0)}–${(
|
||
cadence[1] / 1000
|
||
).toFixed(cadence[1] % 1000 ? 1 : 0)} s`
|
||
: 'désactivé'
|
||
|
||
const randomDelay = cadence => cadence[0] + Math.random() * (cadence[1] - cadence[0])
|
||
|
||
function stopStateTimers() {
|
||
clearTimeout(expressionTimer)
|
||
clearTimeout(blinkTimer)
|
||
expressionTimer = null
|
||
blinkTimer = null
|
||
}
|
||
|
||
function scheduleStateExpression() {
|
||
clearTimeout(expressionTimer)
|
||
if (!$('auto-expression').checked) return
|
||
const cadence = expressionCadences[activeState]
|
||
expressionTimer = setTimeout(() => {
|
||
const pool = statePools[activeState]
|
||
const alternatives = pool.filter(index => index !== activeExpression)
|
||
selectExpression(
|
||
alternatives.length
|
||
? alternatives[Math.floor(Math.random() * alternatives.length)]
|
||
: pool[0]
|
||
)
|
||
scheduleStateExpression()
|
||
}, randomDelay(cadence))
|
||
}
|
||
|
||
function scheduleStateBlink() {
|
||
clearTimeout(blinkTimer)
|
||
const cadence = blinkCadences[activeState]
|
||
if (!$('auto-blink').checked || !cadence) return
|
||
blinkTimer = setTimeout(() => {
|
||
blink()
|
||
scheduleStateBlink()
|
||
}, randomDelay(cadence))
|
||
}
|
||
|
||
function startState(name) {
|
||
activeState = name
|
||
$('active-state').textContent = name
|
||
selectExpression(statePools[name][0])
|
||
scheduleStateExpression()
|
||
scheduleStateBlink()
|
||
}
|
||
|
||
function renderStateDetail() {
|
||
$('state-title').textContent = selectedState
|
||
$('state-description').textContent =
|
||
stateNotes[selectedState] ||
|
||
'Cet état combine son pool d’expressions, ses cadences et ses clignements.'
|
||
$('state-expression-cadence').textContent = formatCadence(
|
||
expressionCadences[selectedState]
|
||
)
|
||
$('state-blink-cadence').textContent = formatCadence(blinkCadences[selectedState])
|
||
$('state-pool').innerHTML = ''
|
||
statePools[selectedState].forEach(index => {
|
||
const button = document.createElement('button')
|
||
button.type = 'button'
|
||
button.className = 'pool-button'
|
||
button.dataset.expression = String(index)
|
||
button.textContent = String(index).padStart(2, '0')
|
||
button.setAttribute('aria-pressed', String(index === activeExpression))
|
||
button.addEventListener('click', () => selectExpression(index))
|
||
$('state-pool').appendChild(button)
|
||
})
|
||
}
|
||
|
||
function renderExpressionGrid() {
|
||
$('expression-grid').replaceChildren()
|
||
expressionPresets.forEach((preset, index) => {
|
||
const button = document.createElement('button')
|
||
const number = String(index).padStart(2, '0')
|
||
button.type = 'button'
|
||
button.className = 'expression-button'
|
||
button.dataset.expression = String(index)
|
||
button.setAttribute('aria-label', `Expression ${number} — double-clic pour modifier`)
|
||
button.setAttribute('aria-pressed', String(index === activeExpression))
|
||
button.innerHTML = `${expressionPreviewSvg(
|
||
preset,
|
||
`preset-${index}`
|
||
)}<span class="expression-number">${number}</span>`
|
||
button.addEventListener('click', () => selectExpression(index))
|
||
button.addEventListener('dblclick', () => openExpressionEditor(index))
|
||
$('expression-grid').appendChild(button)
|
||
})
|
||
const addButton = document.createElement('button')
|
||
addButton.type = 'button'
|
||
addButton.className = 'expression-add'
|
||
addButton.setAttribute('aria-label', 'Créer une nouvelle expression')
|
||
addButton.title = 'Créer une expression depuis les réglages actuels'
|
||
addButton.textContent = '+'
|
||
addButton.addEventListener('click', () => openExpressionEditor())
|
||
$('expression-grid').appendChild(addButton)
|
||
}
|
||
|
||
buildExpressionEditorFields()
|
||
renderExpressionGrid()
|
||
|
||
Object.entries(stateGroups).forEach(([label, states]) => {
|
||
const section = document.createElement('section')
|
||
const title = document.createElement('strong')
|
||
const buttons = document.createElement('div')
|
||
title.className = 'state-group-title'
|
||
title.textContent = label
|
||
buttons.className = 'state-buttons'
|
||
states.forEach(name => {
|
||
const button = document.createElement('button')
|
||
button.type = 'button'
|
||
button.className = 'state-button'
|
||
button.dataset.state = name
|
||
button.textContent = name
|
||
button.setAttribute('aria-pressed', String(name === selectedState))
|
||
button.addEventListener('click', () => {
|
||
selectedState = name
|
||
document.querySelectorAll('[data-state]').forEach(candidate => {
|
||
candidate.setAttribute('aria-pressed', String(candidate === button))
|
||
})
|
||
renderStateDetail()
|
||
})
|
||
buttons.appendChild(button)
|
||
})
|
||
section.append(title, buttons)
|
||
$('state-groups').appendChild(section)
|
||
})
|
||
|
||
document.querySelectorAll('[data-mode]').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
const mode = tab.dataset.mode
|
||
document.querySelectorAll('[data-mode]').forEach(candidate => {
|
||
candidate.setAttribute('aria-selected', String(candidate === tab))
|
||
})
|
||
document.querySelectorAll('.mode-panel').forEach(panel => {
|
||
panel.hidden = panel.id !== `mode-${mode}`
|
||
})
|
||
})
|
||
})
|
||
|
||
renderStateDetail()
|
||
|
||
const pairedDimensions = ['width', 'height']
|
||
const pairedControlIds = new Set(
|
||
pairedDimensions.flatMap(dimension => [`${dimension}-left`, `${dimension}-right`])
|
||
)
|
||
controls
|
||
.filter(id => !pairedControlIds.has(id))
|
||
.forEach(id =>
|
||
$(id).addEventListener('input', () => {
|
||
cancelExpressionAnimation()
|
||
if (id === 'head-x' || id === 'head-y' || id === 'head-z') {
|
||
const axis = id.slice(-1)
|
||
applyAxisControlValue(axis, Number($(id).value))
|
||
}
|
||
render()
|
||
})
|
||
)
|
||
pairedDimensions.forEach(dimension => {
|
||
;['left', 'right'].forEach(side => {
|
||
$(`${dimension}-${side}`).addEventListener('input', event => {
|
||
cancelExpressionAnimation()
|
||
if (linkedDimensions[dimension]) {
|
||
const otherSide = side === 'left' ? 'right' : 'left'
|
||
$(`${dimension}-${otherSide}`).value = event.currentTarget.value
|
||
}
|
||
render()
|
||
})
|
||
})
|
||
})
|
||
;['left', 'right'].forEach(side => {
|
||
$(`scale-${side}`).addEventListener('input', event => {
|
||
cancelExpressionAnimation()
|
||
resizeEyesFromSizeControl(side === 'left' ? -1 : 1, Number(event.currentTarget.value))
|
||
render()
|
||
})
|
||
})
|
||
document.querySelectorAll('[data-link]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
cancelExpressionAnimation()
|
||
const dimension = button.dataset.link
|
||
linkedDimensions[dimension] = !linkedDimensions[dimension]
|
||
button.setAttribute('aria-pressed', String(linkedDimensions[dimension]))
|
||
if (linkedDimensions[dimension] && dimension !== 'scale') {
|
||
$(`${dimension}-right`).value = $(`${dimension}-left`).value
|
||
}
|
||
render()
|
||
})
|
||
})
|
||
;['show-points', 'show-wire'].forEach(id => $(id).addEventListener('change', render))
|
||
document
|
||
.querySelectorAll('[data-preset]')
|
||
.forEach(button =>
|
||
button.addEventListener('click', () => applyPreset(button.dataset.preset))
|
||
)
|
||
|
||
$('spring-speed').addEventListener('input', () => {
|
||
$('spring-speed-output').textContent = Number($('spring-speed').value).toFixed(1)
|
||
})
|
||
$('blink-duration').addEventListener('input', () => {
|
||
$('blink-duration-output').textContent = `${$('blink-duration').value} ms`
|
||
})
|
||
$('blink-now').addEventListener('click', blink)
|
||
$('random-expression').addEventListener('click', () => {
|
||
let next = Math.floor(Math.random() * expressionPresets.length)
|
||
if (next === activeExpression) next = (next + 1) % expressionPresets.length
|
||
selectExpression(next)
|
||
})
|
||
$('expression-editor-close').addEventListener('click', closeExpressionEditor)
|
||
$('expression-editor-cancel').addEventListener('click', closeExpressionEditor)
|
||
$('expression-editor').addEventListener('click', event => {
|
||
if (event.target === $('expression-editor')) closeExpressionEditor()
|
||
})
|
||
$('expression-editor-form').addEventListener('submit', event => {
|
||
event.preventDefault()
|
||
const index = editingExpressionIndex ?? expressionPresets.length
|
||
if (editingExpressionIndex === null) {
|
||
expressionPresets.push({ ...expressionDraft })
|
||
} else {
|
||
expressionPresets[index] = { ...expressionDraft }
|
||
}
|
||
closeExpressionEditor()
|
||
activeExpression = index
|
||
renderExpressionGrid()
|
||
renderStateDetail()
|
||
selectExpression(index)
|
||
})
|
||
$('apply-state').addEventListener('click', () => startState(selectedState))
|
||
$('stop-state').addEventListener('click', stopStateTimers)
|
||
$('auto-expression').addEventListener('change', scheduleStateExpression)
|
||
$('auto-blink').addEventListener('change', scheduleStateBlink)
|
||
|
||
$('reset').addEventListener('click', () => {
|
||
stopStateTimers()
|
||
cancelExpressionAnimation()
|
||
if (blinkFrame !== null) cancelAnimationFrame(blinkFrame)
|
||
blinkFrame = null
|
||
blinkFactor = 1
|
||
Object.entries(defaultValues).forEach(([id, value]) => {
|
||
$(id).value = value
|
||
})
|
||
$('show-points').checked = false
|
||
$('show-wire').checked = false
|
||
selectedSide = null
|
||
headOrientation = [1, 0, 0, 0]
|
||
viewRollDegrees = 0
|
||
selectedRotationControl = null
|
||
activeExpression = 0
|
||
activeState = 'idle'
|
||
selectedState = 'idle'
|
||
$('active-expression').textContent = '00'
|
||
$('active-state').textContent = 'idle'
|
||
document.querySelectorAll('[data-expression]').forEach(button => {
|
||
button.setAttribute('aria-pressed', String(Number(button.dataset.expression) === 0))
|
||
})
|
||
document.querySelectorAll('[data-state]').forEach(button => {
|
||
button.setAttribute('aria-pressed', String(button.dataset.state === 'idle'))
|
||
})
|
||
;['width', 'height', 'scale'].forEach(dimension => {
|
||
linkedDimensions[dimension] = true
|
||
document
|
||
.querySelector(`[data-link="${dimension}"]`)
|
||
.setAttribute('aria-pressed', 'true')
|
||
})
|
||
renderStateDetail()
|
||
syncOrientationFromControls()
|
||
applyPreset('neutral')
|
||
})
|
||
|
||
function clientToSvg(event) {
|
||
const rectangle = $('avatar').getBoundingClientRect()
|
||
return [
|
||
((event.clientX - rectangle.left) / rectangle.width) * 300 - 150,
|
||
((event.clientY - rectangle.top) / rectangle.height) * 300 - 150,
|
||
]
|
||
}
|
||
|
||
function unitVector(from, to) {
|
||
const x = to[0] - from[0]
|
||
const y = to[1] - from[1]
|
||
const length = Math.hypot(x, y) || 1
|
||
return [x / length, y / length]
|
||
}
|
||
|
||
function pointerAngle(point, center) {
|
||
return Math.atan2(point[1] - center[1], point[0] - center[0])
|
||
}
|
||
|
||
function normalizedAngleDelta(angle) {
|
||
return Math.atan2(Math.sin(angle), Math.cos(angle))
|
||
}
|
||
|
||
function arcballVector(point) {
|
||
const x = point[0] / radius
|
||
const y = point[1] / radius
|
||
const squaredLength = x * x + y * y
|
||
if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)]
|
||
const length = Math.sqrt(squaredLength)
|
||
return [x / length, y / length, 0]
|
||
}
|
||
|
||
function beginHeadDrag(event) {
|
||
cancelExpressionAnimation()
|
||
selectedSide = null
|
||
selectedRotationControl = 'arcball'
|
||
interaction = {
|
||
type: 'arcball',
|
||
pointerId: event.pointerId,
|
||
startVector: arcballVector(clientToSvg(event)),
|
||
startOrientation: [...headOrientation],
|
||
}
|
||
$('avatar').setPointerCapture(event.pointerId)
|
||
render()
|
||
}
|
||
|
||
function selectEye(side, event) {
|
||
event.stopPropagation()
|
||
selectedSide = side
|
||
render()
|
||
}
|
||
|
||
function beginViewRollDrag(event) {
|
||
cancelExpressionAnimation()
|
||
event.stopPropagation()
|
||
selectedRotationControl = 'view'
|
||
const point = clientToSvg(event)
|
||
interaction = {
|
||
type: 'view-roll',
|
||
pointerId: event.pointerId,
|
||
center: gizmoCenter,
|
||
startPointerAngle: pointerAngle(point, gizmoCenter),
|
||
startOrientation: [...headOrientation],
|
||
startViewRoll: viewRollDegrees,
|
||
startControlValues: { ...headControlValues },
|
||
controlWeights: cameraRollWeights(headOrientation),
|
||
}
|
||
$('avatar').setPointerCapture(event.pointerId)
|
||
}
|
||
|
||
function beginAxisDrag(axis, event) {
|
||
cancelExpressionAnimation()
|
||
event.stopPropagation()
|
||
selectedRotationControl = axis
|
||
const point = clientToSvg(event)
|
||
const startOrientation = [...headOrientation]
|
||
interaction = {
|
||
type: 'axis-ring',
|
||
axis,
|
||
pointerId: event.pointerId,
|
||
startPoint: point,
|
||
tangent: ringTangentAtPoint(axis, point),
|
||
worldAxis: rotateWithQuaternion(startOrientation, axisVector(axis)),
|
||
startOrientation,
|
||
startValue: headControlValues[axis],
|
||
}
|
||
$('avatar').setPointerCapture(event.pointerId)
|
||
}
|
||
|
||
function beginHandleDrag(type, event) {
|
||
cancelExpressionAnimation()
|
||
event.stopPropagation()
|
||
if (selectedSide === null) return
|
||
const point = clientToSvg(event)
|
||
const { width, height } = eyeSettings(selectedSide)
|
||
const center = projectEyePoint(selectedSide, 0, 0)
|
||
const widthPoint = projectEyePoint(selectedSide, width / 2 + 9, 0)
|
||
const heightPoint = projectEyePoint(selectedSide, 0, -height / 2 - 9)
|
||
const leftCenter = projectEyePoint(-1, 0, 0)
|
||
const rightCenter = projectEyePoint(1, 0, 0)
|
||
interaction = {
|
||
type,
|
||
pointerId: event.pointerId,
|
||
startPoint: point,
|
||
center,
|
||
widthAxis: unitVector(center, widthPoint),
|
||
heightAxis: unitVector(center, heightPoint),
|
||
spacingAxis: unitVector(leftCenter, rightCenter),
|
||
startPointerAngle: pointerAngle(point, center),
|
||
startWidth: Number($(dimensionId('width', selectedSide)).value),
|
||
startHeight: Number($(dimensionId('height', selectedSide)).value),
|
||
startDimensions: Object.fromEntries(
|
||
['width-left', 'width-right', 'height-left', 'height-right'].map(id => [
|
||
id,
|
||
Number($(id).value),
|
||
])
|
||
),
|
||
startSpacing: Number($('spacing').value),
|
||
startAngle: Number($(selectedSide < 0 ? 'left-angle' : 'right-angle').value),
|
||
startDistance: Math.max(Math.hypot(point[0] - center[0], point[1] - center[1]), 1),
|
||
side: selectedSide,
|
||
}
|
||
$('avatar').setPointerCapture(event.pointerId)
|
||
}
|
||
|
||
function continueInteraction(event) {
|
||
if (!interaction || interaction.pointerId !== event.pointerId) return
|
||
if (interaction.type === 'arcball') {
|
||
const currentVector = arcballVector(clientToSvg(event))
|
||
const deltaRotation = quaternionFromVectors(interaction.startVector, currentVector)
|
||
headOrientation = multiplyQuaternions(deltaRotation, interaction.startOrientation)
|
||
syncControlsFromOrientation()
|
||
render()
|
||
return
|
||
}
|
||
|
||
const point = clientToSvg(event)
|
||
if (interaction.type === 'view-roll') {
|
||
const deltaAngle = normalizedAngleDelta(
|
||
pointerAngle(point, interaction.center) - interaction.startPointerAngle
|
||
)
|
||
headOrientation = multiplyQuaternions(
|
||
quaternionFromAxisAngle([0, 0, 1], deltaAngle),
|
||
interaction.startOrientation
|
||
)
|
||
const deltaDegrees = (deltaAngle * 180) / Math.PI
|
||
viewRollDegrees = interaction.startViewRoll + deltaDegrees
|
||
;['x', 'y', 'z'].forEach(axis => {
|
||
const nextValue = clamp(
|
||
interaction.startControlValues[axis] +
|
||
deltaDegrees * interaction.controlWeights[axis],
|
||
-365,
|
||
365
|
||
)
|
||
headControlValues[axis] = nextValue
|
||
$(`head-${axis}`).value = nextValue
|
||
})
|
||
render()
|
||
return
|
||
}
|
||
|
||
if (interaction.type === 'axis-ring') {
|
||
const displacement = [
|
||
point[0] - interaction.startPoint[0],
|
||
point[1] - interaction.startPoint[1],
|
||
]
|
||
const signedDistance =
|
||
displacement[0] * interaction.tangent[0] + displacement[1] * interaction.tangent[1]
|
||
const deltaDegrees = signedDistance * 1.5
|
||
const nextValue = clamp(interaction.startValue + deltaDegrees, -365, 365)
|
||
headOrientation = multiplyQuaternions(
|
||
quaternionFromAxisAngle(
|
||
interaction.worldAxis,
|
||
radians(nextValue - interaction.startValue)
|
||
),
|
||
interaction.startOrientation
|
||
)
|
||
headControlValues[interaction.axis] = nextValue
|
||
$(`head-${interaction.axis}`).value = nextValue
|
||
render()
|
||
return
|
||
}
|
||
|
||
const delta = [point[0] - interaction.startPoint[0], point[1] - interaction.startPoint[1]]
|
||
const along = axis => delta[0] * axis[0] + delta[1] * axis[1]
|
||
|
||
if (interaction.type === 'width') {
|
||
setEyeDimension(
|
||
'width',
|
||
interaction.side,
|
||
clamp(interaction.startWidth + along(interaction.widthAxis) * 2, 12, 100)
|
||
)
|
||
} else if (interaction.type === 'height') {
|
||
setEyeDimension(
|
||
'height',
|
||
interaction.side,
|
||
clamp(interaction.startHeight + along(interaction.heightAxis) * 2, 5, 100)
|
||
)
|
||
} else if (interaction.type === 'scale') {
|
||
const distance = Math.hypot(
|
||
point[0] - interaction.center[0],
|
||
point[1] - interaction.center[1]
|
||
)
|
||
const factor = clamp(distance / interaction.startDistance, 0.2, 4)
|
||
resizeEye(interaction.side, factor, interaction.startDimensions)
|
||
if (linkedDimensions.scale) {
|
||
resizeEye(-interaction.side, factor, interaction.startDimensions)
|
||
}
|
||
} else if (interaction.type === 'spacing') {
|
||
$('spacing').value = clamp(
|
||
interaction.startSpacing + along(interaction.spacingAxis),
|
||
8,
|
||
82
|
||
)
|
||
} else if (interaction.type === 'rotate') {
|
||
const deltaAngle = normalizedAngleDelta(
|
||
pointerAngle(point, interaction.center) - interaction.startPointerAngle
|
||
)
|
||
$(interaction.side < 0 ? 'left-angle' : 'right-angle').value = clamp(
|
||
interaction.startAngle + (deltaAngle * 180) / Math.PI,
|
||
-365,
|
||
365
|
||
)
|
||
}
|
||
render()
|
||
}
|
||
|
||
function endInteraction(event) {
|
||
if (!interaction || interaction.pointerId !== event.pointerId) return
|
||
interaction = null
|
||
}
|
||
|
||
$('avatar').querySelector('.head').addEventListener('pointerdown', beginHeadDrag)
|
||
$('view-roll-handle').addEventListener('pointerdown', beginViewRollDrag)
|
||
$('view-roll-ring').addEventListener('pointerdown', beginViewRollDrag)
|
||
document.querySelectorAll('[data-rotation-axis]').forEach(ring => {
|
||
ring.addEventListener('pointerdown', event =>
|
||
beginAxisDrag(ring.dataset.rotationAxis, event)
|
||
)
|
||
})
|
||
$('left-eye').addEventListener('pointerdown', event => selectEye(-1, event))
|
||
$('right-eye').addEventListener('pointerdown', event => selectEye(1, event))
|
||
document.querySelectorAll('[data-handle]').forEach(handle => {
|
||
handle.addEventListener('pointerdown', event =>
|
||
beginHandleDrag(handle.dataset.handle, event)
|
||
)
|
||
})
|
||
$('avatar').addEventListener('pointermove', continueInteraction)
|
||
$('avatar').addEventListener('pointerup', endInteraction)
|
||
$('avatar').addEventListener('pointercancel', endInteraction)
|
||
|
||
syncOrientationFromControls()
|
||
render()
|
||
})()
|
||
</script>
|
||
</body>
|
||
</html>
|