feat:加上拍照模式与自定义颜色渐变

This commit is contained in:
Edom
2026-08-22 22:56:46 +08:00
parent 62f39d27e7
commit f3b74b572a
23 changed files with 2707 additions and 126 deletions
+84 -7
View File
@@ -73,6 +73,7 @@
this.sizePx = opts.sizePx || null;
this.eyeColor = opts.eyeColor || null;
this.inkFlat = opts.inkFlat || null;
this.inkGrad = opts.inkGrad || null;
this.geo = opts.geo || g.GROK_GEO;
this.playlists = opts.playlist || EYE_PLAYLIST;
@@ -421,20 +422,65 @@
setColor(id, scheme) {
this.colorId = id;
if (scheme) this.scheme = scheme;
if (this.inkFlat) {
this.svg.style.setProperty("--fg", this.inkFlat);
} else if (this.loginWrap) {
this.svg.style.setProperty("--fg", inkFg(id));
if (this.inkGrad) {
this._paintInkGrad();
} else {
const pal = this.geo.palette[id] || this.geo.palette.black;
this.svg.style.setProperty("--fg", this.scheme === "dark" ? pal.dark : pal.light);
if (this.inkFlat) {
this.svg.style.setProperty("--fg", this.inkFlat);
} else if (this.loginWrap) {
this.svg.style.setProperty("--fg", inkFg(id));
} else {
const pal = this.geo.palette[id] || this.geo.palette.black;
this.svg.style.setProperty("--fg", this.scheme === "dark" ? pal.dark : pal.light);
}
this.svg.style.setProperty("--ink", inkCss(id));
this.body.setAttribute("fill", "var(--fg, #000)");
}
this.svg.style.setProperty("--ink", inkCss(id));
this.svg.style.setProperty("--bg", this.eyeColor || EYE_BG);
}
_paintInkGrad() {
const spec = this.inkGrad;
const vb = this.geo.viewBox;
const cx = vb.minX + vb.width / 2;
const cy = vb.minY + vb.height / 2;
if (spec.radial) {
const r = (Math.hypot(vb.width, vb.height) / 2) * 0.72;
this.inkRadialEl.setAttribute("cx", cx);
this.inkRadialEl.setAttribute("cy", vb.minY + vb.height * 0.42);
this.inkRadialEl.setAttribute("r", r);
this.inkRadialStops[0].setAttribute("stop-color", spec.from);
this.inkRadialStops[1].setAttribute("stop-color", spec.to);
this.body.setAttribute("fill", `url(#${this.inkRadialId})`);
this.svg.style.setProperty("--fg", spec.from);
this.svg.style.setProperty("--ink", `radial-gradient(circle at 50% 42%, ${spec.from}, ${spec.to})`);
return;
}
const deg = ((Number(spec.angle) || 0) % 360 + 360) % 360;
const rad = (deg * Math.PI) / 180;
const span = Math.hypot(vb.width, vb.height) / 2;
const dx = Math.sin(rad) * span;
const dy = -Math.cos(rad) * span;
this.inkGradEl.setAttribute("x1", cx - dx);
this.inkGradEl.setAttribute("y1", cy - dy);
this.inkGradEl.setAttribute("x2", cx + dx);
this.inkGradEl.setAttribute("y2", cy + dy);
this.inkStops[0].setAttribute("stop-color", spec.from);
this.inkStops[1].setAttribute("stop-color", spec.to);
this.body.setAttribute("fill", `url(#${this.inkGradId})`);
this.svg.style.setProperty("--fg", spec.from);
this.svg.style.setProperty("--ink", `linear-gradient(${deg}deg, ${spec.from}, ${spec.to})`);
}
setInk(flat) {
this.inkFlat = flat || null;
if (this.inkFlat) this.inkGrad = null;
this.setColor(this.colorId);
}
setInkGrad(grad) {
this.inkGrad = grad ? { ...grad } : null;
if (this.inkGrad) this.inkFlat = null;
this.setColor(this.colorId);
}
@@ -738,6 +784,28 @@
this.clipPath = document.createElementNS(ns, "path");
clip.appendChild(this.clipPath);
defs.appendChild(clip);
this.inkGradId = `grok-ink-${Math.random().toString(36).slice(2, 8)}`;
this.inkGradEl = document.createElementNS(ns, "linearGradient");
this.inkGradEl.setAttribute("id", this.inkGradId);
this.inkGradEl.setAttribute("gradientUnits", "userSpaceOnUse");
this.inkStops = [0, 1].map((offset) => {
const s = document.createElementNS(ns, "stop");
s.setAttribute("offset", String(offset));
this.inkGradEl.appendChild(s);
return s;
});
defs.appendChild(this.inkGradEl);
this.inkRadialId = `grok-ink-r-${Math.random().toString(36).slice(2, 8)}`;
this.inkRadialEl = document.createElementNS(ns, "radialGradient");
this.inkRadialEl.setAttribute("id", this.inkRadialId);
this.inkRadialEl.setAttribute("gradientUnits", "userSpaceOnUse");
this.inkRadialStops = [0, 1].map((offset) => {
const s = document.createElementNS(ns, "stop");
s.setAttribute("offset", String(offset));
this.inkRadialEl.appendChild(s);
return s;
});
defs.appendChild(this.inkRadialEl);
this.svg.appendChild(defs);
this.group = document.createElementNS(ns, "g");
@@ -1318,12 +1386,21 @@
manualMix: 0,
});
if (holdMix > 0.002) {
// Eye-sphere rim = visible head half-width in overlay units, so the
// fold/slice lands on the silhouette like the original lab.
let rim = 95;
if (restRing) {
let hw = 0;
for (const p of restRing) hw = Math.max(hw, Math.abs(p[0] - R));
rim = (hw * 300) / (R * 3.16);
}
EY.blendCustomEyes({
eyeEls: this.eyeEls,
pose: this._poseNow(),
eyes: this.eyeTune,
svg: this.svg,
mix: holdMix,
rim,
});
}
+28 -13
View File
@@ -301,19 +301,25 @@
return pts;
}
function projectEye(pose, home, faceX, faceY, lx, ly, angle) {
function projectEye(pose, home, faceX, faceY, lx, ly, angle, rim) {
const a = (angle || 0) * Math.PI / 180;
const c = Math.cos(a);
const s = Math.sin(a);
const x = faceX + lx * c - ly * s;
const y = faceY + lx * s + ly * c;
const lon = x / FACE_R;
const lat = y / FACE_R;
const cl = Math.cos(lat);
const rotated = quatRotate(screenOf(pose, home), [cl * Math.sin(lon), Math.sin(lat), cl * Math.cos(lon)]);
const depth = rotated[2] * FACE_R;
// Face coords are authored against FACE_R=120 (original lab units) but the
// physical head rides `rim` overlay units so the eye sphere's silhouette
// lands on the visible head edge. Past the rim points stay raw on the z=0
// plane (ellipsoidFrontSample semantics): the eye rides over the edge and
// gets sliced by the head clip instead of folding back inward.
const headR = rim || 95;
const dx = x / FACE_R;
const dy = y / FACE_R;
const dz = Math.sqrt(Math.max(0, 1 - dx * dx - dy * dy));
const rotated = quatRotate(screenOf(pose, home), [dx, dy, dz]);
const depth = rotated[2] * headR;
const sc = FOCAL / Math.max(FOCAL - depth, 0.0001);
return [rotated[0] * FACE_R * sc, rotated[1] * FACE_R * sc, rotated[2]];
return [rotated[0] * headR * sc, rotated[1] * headR * sc, rotated[2]];
}
function overlayToSvg(svg, x, y) {
@@ -369,7 +375,7 @@
return pts;
}
function customEyePolys({ pose, eyes, svg, n = 48, home = EYE_HOME }) {
function customEyePolys({ pose, eyes, svg, n = 48, home = EYE_HOME, rim }) {
if (!svg || !pose) return null;
const src = eyes || DEFAULT_EYES;
const sides = ["left", "right"];
@@ -380,18 +386,23 @@
const local = roundedRect(Math.max(tune.width, 1), Math.max(tune.height, 1));
let z = 0;
const projected = local.map(([lx, ly]) => {
const p = projectEye(pose, home, faceX, faceY, lx, ly, tune.angle);
const p = projectEye(pose, home, faceX, faceY, lx, ly, tune.angle, rim);
z += p[2];
return overlayToSvg(svg, p[0], p[1]);
});
return { pts: resampleRing(projected, n), visible: z > 0 };
return { pts: resampleRing(projected, n), visible: z > 0, zAvg: z / Math.max(local.length, 1) };
});
}
function blendCustomEyes({ eyeEls, pose, eyes, svg, mix, n = 48 }) {
// Visibility hysteresis: drag jitter around the silhouette boundary would
// otherwise pop the eye in and out every few frames.
const VIS_HIDE = -0.006;
const VIS_SHOW = 0.004;
function blendCustomEyes({ eyeEls, pose, eyes, svg, mix, n = 48, rim }) {
const k = clamp(mix, 0, 1);
if (k <= 0.002 || !eyeEls) return;
const custom = customEyePolys({ pose, eyes, svg, n });
const custom = customEyePolys({ pose, eyes, svg, n, rim });
if (!custom) return;
const gate = 0.22;
const shapeK = k >= gate ? 1 : Dke(k / gate);
@@ -399,9 +410,13 @@
const el = eyeEls[i];
const expr = sampleEl(el, n);
if (!el || !expr || !custom[i]) continue;
let shown = el.__eyeShown != null ? el.__eyeShown : custom[i].visible;
if (shown && custom[i].zAvg < VIS_HIDE) shown = false;
if (!shown && custom[i].zAvg > VIS_SHOW) shown = true;
el.__eyeShown = shown;
el.setAttribute("d", polyPath(lerpPoly(custom[i].pts, expr, 1 - shapeK)));
el.removeAttribute("transform");
if (shapeK > 0.55) el.style.display = custom[i].visible ? "" : "none";
if (shapeK > 0.55) el.style.display = shown ? "" : "none";
}
}
+289 -36
View File
@@ -7,6 +7,8 @@ import CycleDialog from '@/components/CycleDialog.vue'
import ExportBar from '@/components/ExportBar.vue'
import GifDialog from '@/components/GifDialog.vue'
import AvatarStage from '@/components/AvatarStage.vue'
import PhotoFrame from '@/components/PhotoFrame.vue'
import PhotoPanel from '@/components/PhotoPanel.vue'
import Settings from '@/components/Settings.vue'
import SideRail, { type ViewId } from '@/components/SideRail.vue'
import Timeline from '@/components/Timeline.vue'
@@ -47,11 +49,19 @@ import {
cyclePas,
nomFichier,
tailleAction,
viewBoxExport,
type ActionId,
type EtatExport,
type FondGif,
type FormatCycle
} from '@/ui/export'
import {
ANGLE_DEFAUT,
COMPOSITION_DEFAUT,
svgPhoto,
type CompositionPhoto,
type FondPhoto
} from '@/ui/photo'
import { parseManual, persistable, type ManualState } from '@/ui/pose/model'
import { ecris, lis } from '@/ui/stockage'
@@ -61,6 +71,7 @@ const ready = ref(false)
const savedShape = lis('forme')
const shape = ref(savedShape && estForme(savedShape) ? savedShape : DEFAULT_SHAPE)
const color = ref(lis('couleur') || DEFAULT_COLOR)
const oeil = ref(lis('oeil') || '')
const expression = ref(lis('expression') || DEFAULT_EXPRESSION)
const intro = ref(true)
const hero = ref<{
@@ -85,6 +96,20 @@ const hero = ref<{
} | null>(null)
const manual = ref<ManualState>(parseManual(lis('manuel')))
/* ---- mode photo : cadrage, fond et capture de l'avatar fige ---- */
const photo = ref(false)
const photoOutil = ref<'cadre' | 'pose'>('cadre')
const photoComposition = ref<CompositionPhoto>({ ...COMPOSITION_DEFAUT, cornerRadius: 18 })
const photoFond = ref<FondPhoto>('transparent')
const photoCouleurDe = ref('#F5F7FC')
const photoCouleurA = ref('#C9D5FF')
const photoAngle = ref(ANGLE_DEFAUT)
const photoFormat = ref<'png' | 'svg'>('png')
const photoTaille = ref(1024)
const photoFlash = ref(0)
const photoAvantJoue = ref(true)
let minuteurPhoto: ReturnType<typeof setTimeout> | undefined
const restored = parseCycles(lis('cycles'))
const cycles = ref<Cycle[]>(restored.length ? restored : [defaultCycle()])
const activeId = ref(
@@ -103,18 +128,23 @@ const state = ref(intro.value ? 'idle' : (cycle.value.blocks[block.value]?.state
const gauche = computed(() => view.value === 'reglages' && !preview.value)
const droite = computed(() => view.value !== 'reglages' && !preview.value && !intro.value)
const plein = computed(() => preview.value || photo.value)
const follow = computed(() => view.value === 'reglages' && !preview.value)
const nue = computed(() => intro.value && !preview.value)
const playedState = computed(() => {
if (photo.value) return expression.value
if (view.value === 'animations' || preview.value) return state.value
return expression.value
})
const avatarSize = computed(() => {
if (photo.value) return 460
if (preview.value) return 560
if (view.value === 'personnaliser') return 580
return 440
})
const avatarMax = computed(() => {
if (photo.value)
return 'max-w-[min(460px,68vw,calc(100dvh_-_14rem))] max-lg:max-w-[calc(54dvh_-_12.5rem)]'
if (preview.value) return 'max-w-[min(560px,calc(100dvh_-_6rem))]'
if (view.value === 'personnaliser')
return 'max-w-[min(600px,calc(100dvh_-_var(--timeline)_-_7rem))]'
@@ -126,8 +156,17 @@ watch(shape, (id) => {
if (manual.value.selected) manual.value = { ...manual.value, selected: null }
})
watch(color, (id) => ecris('couleur', id))
watch(oeil, (v) => ecris('oeil', v))
watch(expression, (id) => {
ecris('expression', id)
if (photo.value) {
// en photo, l'avatar est fige : on libere le temps de la transition
// spring vers la nouvelle expression, puis on regele.
hero.value?.setPaused(false)
clearTimeout(minuteurPhoto)
minuteurPhoto = setTimeout(() => hero.value?.freezeNow({ settle: true }), 700)
return
}
if (view.value !== 'animations') state.value = id
if (view.value !== 'personnaliser') return
poseIdx.value = 0
@@ -203,7 +242,13 @@ watch(
if (view.value !== 'personnaliser') return
if (on) {
posePlay.value = false
// en photo, l'entree a gele le moteur : le degeler pour que le
// ressort manualMix reparte et laisse apparaitre les gizmos.
if (photo.value) hero.value?.setPaused(false)
pausePose()
} else {
posePlay.value = true
releasePoseFreeze()
}
}
)
@@ -247,10 +292,95 @@ function onStopPose() {
if (view.value === 'personnaliser') pausePose(true)
}
function ouvrePhoto() {
if (!hero.value || intro.value || preview.value) return
// fige l'instant present : l'expression affichee devient la pose photo.
expression.value = playedState.value
photoOutil.value = 'cadre'
photoAvantJoue.value = posePlay.value
posePlay.value = false
poseStopped.value = false
pausePose(true)
photo.value = true
}
function fermePhoto() {
photo.value = false
clearTimeout(minuteurPhoto)
photoFlash.value = 0
hero.value?.setPaused(false)
posePlay.value = photoAvantJoue.value && !manual.value.on
void nextTick(applyPosePlayback)
}
function recadrePhoto() {
photoComposition.value = {
...COMPOSITION_DEFAUT,
cornerRadius: photoComposition.value.cornerRadius
}
}
function choisitOutil(outil: 'cadre' | 'pose') {
photoOutil.value = outil
if (outil === 'pose' && !manual.value.on) {
manual.value = { ...manual.value, on: true, selected: manual.value.selected ?? 'body' }
}
}
/**
* Choisir une expression quitte le reglage manuel (la pose revient a
* l'expression) puis enchaine la transition — meme si l'expression cliquee
* est celle d'origine, la grille n'affichant aucune selection en manuel.
*/
function choisitExpression(id: string) {
if (manual.value.on) manual.value = { ...manual.value, on: false, selected: null }
if (id === expression.value) {
hero.value?.setPaused(false)
clearTimeout(minuteurPhoto)
minuteurPhoto = setTimeout(() => hero.value?.freezeNow({ settle: true }), 700)
return
}
expression.value = id
}
async function prendPhoto() {
const svg = hero.value?.svg()
if (!svg || !photo.value || etatExport.value === 'occupe') return
clearTimeout(minuteurPhoto)
hero.value?.freezeNow({ settle: true })
photoFlash.value++
clearTimeout(confirmation)
etatExport.value = 'occupe'
try {
// le viewBox vivant du moment : c'est lui que le cadre affiche.
const vue = svg.getAttribute('viewBox') || viewBoxExport()
const markup = svgAutonome(svg, 300, vue)
const complet = svgPhoto(markup, {
fond: photoFond.value,
couleurDe: photoCouleurDe.value,
couleurA: photoCouleurA.value,
angle: photoAngle.value,
taille: photoTaille.value,
composition: photoComposition.value
})
const nom = nomFichier(shape.value, expression.value, color.value, photoFormat.value, 'photo')
const fichier =
photoFormat.value === 'svg'
? new Blob([complet], { type: 'image/svg+xml' })
: await versPng(complet, photoTaille.value)
telecharge(fichier, nom)
etatExport.value = 'exporte'
} catch {
etatExport.value = 'erreur'
}
confirmation = setTimeout(() => (etatExport.value = 'pret'), CONFIRMATION_MS)
}
const gardeAnim = ref(false)
watch(view, (now, prev) => {
intro.value = false
if (photo.value) fermePhoto()
if (prev === 'personnaliser' && now !== 'personnaliser') releasePoseFreeze()
if (now === 'animations') {
gardeAnim.value = true
@@ -313,9 +443,12 @@ function tick(ms: number) {
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') preview.value = false
if (e.key === 'Escape') {
if (photo.value) fermePhoto()
else preview.value = false
}
if (e.code !== 'Space') return
if (view.value !== 'animations' && !preview.value) return
if (view.value !== 'animations' && !preview.value && !photo.value) return
if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return
const node = e.target
if (
@@ -360,7 +493,12 @@ async function exporteCycle() {
const images = cycleImages(totalDuration(blocs), format)
const pas = cyclePas(format)
const taille = CYCLE_TAILLE[format]
const reglages = { shape: shape.value, color: color.value, expression: expression.value }
const reglages = {
shape: shape.value,
color: color.value,
oeil: oeil.value || undefined,
expression: expression.value
}
const suit = (fait: number, total: number) => (avancementCycle.value = fait / total)
avancementCycle.value = 0
try {
@@ -423,11 +561,21 @@ async function exporte(id: ActionId, confirme = false) {
nomFichier(shape.value, expression.value, color.value, action.extension, action.suffixe)
try {
if (action.mode === 'anime') {
const reglages = { shape: shape.value, color: color.value, expression: expression.value }
const reglages = {
shape: shape.value,
color: color.value,
oeil: oeil.value || undefined,
expression: expression.value
}
telecharge(await versSvgAnime(reglages, cote, ANIM_IMAGES, ANIM_PAS), nom())
etatExport.value = 'exporte'
} else if (action.mode === 'gif') {
const reglages = { shape: shape.value, color: color.value, expression: expression.value }
const reglages = {
shape: shape.value,
color: color.value,
oeil: oeil.value || undefined,
expression: expression.value
}
telecharge(
await versGifAnime(reglages, action.taille, GIF_IMAGES, GIF_PAS, couleurDeFond(fondGif.value)),
nom()
@@ -482,6 +630,7 @@ onUnmounted(() => {
clearTimeout(pending)
clearTimeout(minuteurBarre)
clearTimeout(confirmation)
clearTimeout(minuteurPhoto)
annuleCycle()
})
</script>
@@ -491,7 +640,17 @@ onUnmounted(() => {
<SideRail v-if="!preview" v-model="view" class="rail" :inert="nue || undefined" />
<button
v-if="preview"
v-if="photo"
type="button"
class="photo-sortie fixed top-4 right-4 z-30 flex cursor-pointer items-center gap-1.5 rounded-full border border-[var(--line)] bg-white/90 px-3 py-1.5 text-xs text-[var(--muted)] shadow-sm backdrop-blur transition hover:text-[var(--ink)] lg:right-[calc(21.25rem+1rem)]"
@click="fermePhoto"
>
{{ t('photo.exit') }}
<kbd class="rounded bg-black/5 px-1 py-0.5 text-[10px]">{{ t('preview.key') }}</kbd>
</button>
<button
v-else-if="preview"
type="button"
class="fixed top-5 right-5 z-30 flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/80 px-2.5 py-1.5 text-xs text-[var(--muted)] shadow-sm backdrop-blur transition hover:text-[var(--ink)]"
@click="preview = false"
@@ -500,17 +659,40 @@ onUnmounted(() => {
<kbd class="rounded bg-black/5 px-1 py-0.5 text-[10px]">{{ t('preview.key') }}</kbd>
</button>
<aside
v-if="photo"
class="photo-panneau fixed top-0 right-0 z-20 flex h-dvh w-[21.25rem] flex-col gap-3 overflow-y-auto border-l border-[var(--line)] bg-[var(--paper)] p-4 max-lg:inset-x-0 max-lg:top-auto max-lg:bottom-0 max-lg:h-[46dvh] max-lg:w-auto max-lg:rounded-t-2xl max-lg:border-t"
>
<PhotoPanel
v-model:composition="photoComposition"
v-model:fond="photoFond"
v-model:couleur-de="photoCouleurDe"
v-model:couleur-a="photoCouleurA"
v-model:angle="photoAngle"
:expression="expression"
v-model:format="photoFormat"
v-model:taille="photoTaille"
v-model:shape="shape"
v-model:color="color"
v-model:manual="manual"
:manuel="manual.on"
:active="true"
@update:expression="choisitExpression"
/>
</aside>
<div
class="scene min-h-full items-stretch justify-center p-8 lg:pr-3 max-lg:flex max-lg:flex-col max-lg:gap-10 max-lg:px-5"
:class="[
!preview && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]',
!preview && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]',
!preview && 'max-lg:pt-20',
nue || preview ? 'scene--seule' : view === 'reglages' && 'scene--gauche'
!plein && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]',
!plein && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]',
!preview && !photo && 'max-lg:pt-20',
photo && 'max-lg:pt-40 max-lg:pb-[46dvh]',
nue || preview ? 'scene--seule' : photo ? 'scene--photo' : view === 'reglages' && 'scene--gauche'
]"
>
<aside
v-if="!preview"
v-if="!plein"
class="panneau scene__gauche w-full lg:flex lg:h-[calc(100dvh_-_3rem_-_var(--timeline))] lg:w-80 lg:shrink-0 lg:flex-col lg:justify-center lg:self-start lg:-translate-y-12 lg:pl-14"
:class="gauche ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
>
@@ -520,7 +702,7 @@ onUnmounted(() => {
<main
class="scene__avatar relative flex flex-1 items-center justify-center max-lg:order-1 max-lg:flex-col max-lg:gap-4 lg:self-start"
:class="
preview
plein
? 'lg:min-h-[calc(100dvh_-_4rem)]'
: 'lg:min-h-[calc(100dvh_-_3rem_-_var(--timeline))]'
"
@@ -530,38 +712,108 @@ onUnmounted(() => {
:class="[
avatarMax,
nue && 'avatar--intro',
view === 'reglages' && !preview && 'avatar--geant',
view === 'personnaliser' && !preview && !nue && 'avatar--bas'
view === 'reglages' && !plein && 'avatar--geant',
view === 'personnaliser' && !plein && !nue && 'avatar--bas'
]"
>
<AvatarStage
ref="hero"
class="h-auto max-w-full"
:size="avatarSize"
:shape="shape"
:color="color"
:state="playedState"
:follow="follow"
:manual="manual"
:tools="view === 'personnaliser' && !preview"
:playing="posePlay"
:mix="poseMix"
@ready="onHeroReady"
@update:manual="manual = $event"
/>
<PhotoFrame
v-model:composition="photoComposition"
:active="photo"
:fond="photoFond"
:couleur-de="photoCouleurDe"
:couleur-a="photoCouleurA"
:angle="photoAngle"
:outil="photoOutil"
:flash="photoFlash"
>
<AvatarStage
ref="hero"
class="h-auto max-w-full"
:size="avatarSize"
:shape="shape"
:color="color"
:paper="oeil || '#f9f9f9'"
:state="playedState"
:follow="follow"
:manual="manual"
:tools="(view === 'personnaliser' && !preview && !photo) || photo"
:echelle="photo ? photoComposition.scale : 1"
:playing="posePlay"
:mix="poseMix"
@ready="onHeroReady"
@update:manual="manual = $event"
/>
</PhotoFrame>
</div>
<div
v-if="view === 'personnaliser' && !preview"
class="barre-export flex justify-center"
v-if="photo"
class="photo-barres absolute inset-x-0 bottom-6 z-30 flex flex-col items-center gap-3 max-lg:fixed max-lg:bottom-auto max-lg:top-20 max-lg:left-1/2 max-lg:-translate-x-1/2 max-lg:scale-90"
>
<div class="photo-outils flex items-center gap-1 rounded-full bg-white/90 p-1 shadow-sm backdrop-blur">
<button
v-for="outil in ['cadre', 'pose'] as const"
:key="outil"
type="button"
class="cursor-pointer rounded-full px-3 py-1.5 text-xs transition"
:class="
photoOutil === outil
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
: 'text-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="photoOutil === outil"
@click="choisitOutil(outil)"
>
{{ t(outil === 'cadre' ? 'photo.toolFrame' : 'photo.toolPose') }}
</button>
<button
type="button"
class="cursor-pointer rounded-full px-3 py-1.5 text-xs text-[var(--muted)] transition hover:text-[var(--ink)]"
@click="recadrePhoto"
>
{{ t('photo.reset') }}
</button>
</div>
<button
type="button"
class="photo-declencheur flex cursor-pointer items-center gap-2 rounded-full px-6 py-3 text-sm font-semibold shadow-lg transition"
:class="etatExport === 'occupe' && 'occupe'"
:disabled="etatExport === 'occupe'"
@click="prendPhoto"
>
<span class="photo-obturateur" aria-hidden="true" />
<span>{{
etatExport === 'exporte'
? t('export.done')
: etatExport === 'erreur'
? t('export.failed')
: t('photo.take')
}}</span>
<span class="rounded-full bg-black/15 px-1.5 py-0.5 text-[10px] font-medium uppercase">
{{ photoFormat }}
</span>
</button>
</div>
<div
v-if="view === 'personnaliser' && !plein"
class="barre-export flex justify-center gap-2"
:class="(nue || barreCachee) && 'barre-export--cachee'"
:inert="nue || barreCachee || undefined"
>
<button
type="button"
class="photo-entree flex cursor-pointer items-center gap-1.5 rounded-xl border border-[var(--line)] bg-white px-3 py-2 text-sm text-[var(--muted)] shadow-sm transition hover:border-[var(--muted)] hover:text-[var(--ink)]"
@click="ouvrePhoto"
>
<span class="photo-obturateur photo-obturateur--petit" aria-hidden="true" />
{{ t('photo.open') }}
</button>
<ExportBar :etat="etatExport" @exporter="exporte" />
</div>
<CycleDialog
v-if="view === 'animations' && !preview"
v-if="view === 'animations' && !plein"
v-model:open="dialogueCycle"
v-model:format="formatCycle"
v-model:fond="fondCycle"
@@ -572,7 +824,7 @@ onUnmounted(() => {
/>
<GifDialog
v-if="view === 'personnaliser' && !preview"
v-if="view === 'personnaliser' && !plein"
v-model:open="dialogueGif"
v-model:fond="fondGif"
@confirm="exporte('gif', true)"
@@ -580,7 +832,7 @@ onUnmounted(() => {
</main>
<aside
v-if="!preview"
v-if="!plein"
class="panneau scene__droite w-full lg:w-[21.25rem] lg:shrink-0"
:class="[
droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden',
@@ -592,6 +844,7 @@ onUnmounted(() => {
class="playback-pad"
v-model:shape="shape"
v-model:color="color"
v-model:oeil="oeil"
v-model:expression="expression"
v-model:manual="manual"
:ready="ready"
@@ -626,7 +879,7 @@ onUnmounted(() => {
</div>
<Timeline
v-if="view === 'animations' && !preview"
v-if="view === 'animations' && !plein"
v-model:cycles="cycles"
v-model:active-id="activeId"
v-model:block="block"
@@ -640,5 +893,5 @@ onUnmounted(() => {
@exporter="dialogueCycle = true"
/>
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
<p v-if="view === 'reglages' && !plein" class="wordmark" aria-hidden="true">STUDY</p>
</template>
+4 -1
View File
@@ -17,6 +17,7 @@ const props = withDefaults(
tools?: boolean
playing?: boolean
mix?: number
echelle?: number
}>(),
{
size: 440,
@@ -28,7 +29,8 @@ const props = withDefaults(
manual: null,
tools: false,
playing: false,
mix: 0
mix: 0,
echelle: 1
}
)
@@ -130,6 +132,7 @@ defineExpose({
:manual="manual"
:hero="liveHero"
:shape="shape"
:echelle="echelle"
@update:pose="(pose: HeadPose) => patch({ pose })"
@update:selected="(selected: ManualPart | null) => patch({ selected })"
@update:eyes="(eyes: EyeTune) => patch({ eyes })"
+3 -2
View File
@@ -9,6 +9,7 @@ const props = withDefaults(
state?: string
shape?: string
color?: string
paper?: string
size?: number
eye?: number
interactive?: boolean
@@ -29,7 +30,7 @@ async function paint() {
shape: props.shape ?? 'blob',
color: props.color ?? 'black',
follow: false,
paper: '#f9f9f9',
paper: props.paper ?? '#f9f9f9',
size: props.size ?? 60,
eye: props.eye
})
@@ -41,7 +42,7 @@ const dirty = ref(false)
onMounted(() => {
void paint()
})
watch(() => [props.state, props.shape, props.color, props.eye], () => {
watch(() => [props.state, props.shape, props.color, props.paper, props.eye], () => {
if (!props.active) {
dirty.value = true
return
+404 -24
View File
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import BotTile from '@/components/BotTile.vue'
import { shapeName, stateName, t } from '@/i18n'
import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog'
import {
COLOR_IDS,
EXPRESSION_IDS,
colorFill,
colorHex,
encodeFlat,
encodeGrad,
encodeRadial,
liveShapes,
parseInk
} from '@/replica/catalog'
import { angleDepuisPointeur, PAS_CADRAN, tourAngle } from '@/ui/cadran'
import { type ManualState } from '@/ui/pose/model'
const props = defineProps<{ ready?: boolean; active?: boolean }>()
const shape = defineModel<string>('shape', { required: true })
const color = defineModel<string>('color', { required: true })
const oeil = defineModel<string>('oeil', { required: true })
const expression = defineModel<string>('expression', { required: true })
const manual = defineModel<ManualState>('manual', { required: true })
const shapes = computed(() => {
@@ -15,6 +27,155 @@ const shapes = computed(() => {
return liveShapes()
})
const flatHex = ref('#e02135')
const gradFrom = ref('#ff8838')
const gradTo = ref('#0e74e0')
const gradAngle = ref(135)
{
const spec = parseInk(color.value)
if (spec.kind === 'flat') flatHex.value = spec.hex
else if (spec.kind === 'grad') {
gradFrom.value = spec.from
gradTo.value = spec.to
gradAngle.value = spec.angle
} else if (spec.kind === 'radial') {
gradFrom.value = spec.from
gradTo.value = spec.to
}
}
const OEIL_DEFAUT = '#f9f9f9'
const oeilHex = ref(oeil.value || OEIL_DEFAUT)
const PERSO_FOND =
'conic-gradient(from 0.5turn, #e02135, #e08600, #009957, #1aa8c4, #0e74e0, #804ee0, #e02a88, #e02135)'
const activeSpec = computed(() => parseInk(color.value))
const estPerso = computed(() => {
const spec = activeSpec.value
if (spec.kind !== 'flat') return false
return COLOR_IDS.every((id) => colorHex(id).toLowerCase() !== spec.hex.toLowerCase())
})
const apercu = computed(() =>
activeSpec.value.kind === 'radial'
? `radial-gradient(circle at 50% 42%, ${gradFrom.value}, ${gradTo.value})`
: `linear-gradient(${gradAngle.value}deg, ${gradFrom.value}, ${gradTo.value})`
)
function appliqueUni() {
color.value = encodeFlat(flatHex.value)
}
function appliqueDegrade() {
color.value = encodeGrad(gradFrom.value, gradTo.value, gradAngle.value)
}
function appliqueRadial() {
color.value = encodeRadial(gradFrom.value, gradTo.value)
}
function appliqueCourant() {
if (activeSpec.value.kind === 'radial') appliqueRadial()
else appliqueDegrade()
}
function ensemenceSiPresent() {
const spec = activeSpec.value
if (spec.kind !== 'preset') return
const ink = window.GROK_TABLES?.INK?.[spec.id]
if (ink?.lightFrom && ink?.lightTo) {
gradFrom.value = ink.lightFrom
gradTo.value = ink.lightTo
gradAngle.value = 135
} else {
const hex = colorHex(spec.id)
gradFrom.value = hex
gradTo.value = hex
}
}
function choisitUni() {
const spec = activeSpec.value
if (spec.kind === 'preset') flatHex.value = colorHex(spec.id)
else if (spec.kind === 'grad' || spec.kind === 'radial') flatHex.value = gradFrom.value
appliqueUni()
}
function choisitNuancier(id: string) {
flatHex.value = colorHex(id)
appliqueUni()
}
function saisitPerso(e: Event) {
flatHex.value = (e.target as HTMLInputElement).value
appliqueUni()
}
function estNuancier(id: string) {
const spec = activeSpec.value
if (spec.kind === 'preset') return spec.id === id
if (spec.kind === 'flat') return colorHex(id).toLowerCase() === spec.hex.toLowerCase()
return false
}
function choisitDegrade() {
ensemenceSiPresent()
appliqueDegrade()
}
function choisitRadial() {
ensemenceSiPresent()
appliqueRadial()
}
function choisitAngle(a: number) {
gradAngle.value = tourAngle(a)
appliqueDegrade()
}
function cadranPointe(e: PointerEvent) {
e.preventDefault()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
choisitAngle(angleDepuisPointeur(el, e))
}
function cadranGlisse(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
choisitAngle(angleDepuisPointeur(el, e))
}
function cadranClavier(e: KeyboardEvent) {
const pas = PAS_CADRAN[e.key]
if (pas == null) return
e.preventDefault()
choisitAngle(gradAngle.value + pas)
}
function echangeDegrade() {
;[gradFrom.value, gradTo.value] = [gradTo.value, gradFrom.value]
appliqueCourant()
}
function saisit(cible: 'from' | 'to' | 'oeil', e: Event) {
const v = (e.target as HTMLInputElement).value
if (cible === 'from') {
gradFrom.value = v
appliqueCourant()
} else if (cible === 'to') {
gradTo.value = v
appliqueCourant()
} else {
oeilHex.value = v
oeil.value = v
}
}
function reinitialiseOeil() {
oeil.value = ''
oeilHex.value = OEIL_DEFAUT
}
function patch(next: Partial<ManualState>) {
manual.value = { ...manual.value, ...next }
}
@@ -22,30 +183,27 @@ function patch(next: Partial<ManualState>) {
<template>
<div>
<h2 class="text-sm font-semibold">{{ t('manual.title') }}</h2>
<p class="mt-1 text-xs leading-relaxed text-[var(--muted)]">{{ t('manual.help') }}</p>
<button
type="button"
class="mt-2 flex w-full cursor-pointer items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition"
:class="
manual.on
? 'border-[var(--ink)] bg-white font-medium'
: 'border-[var(--line)] text-[var(--muted)] hover:border-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="manual.on"
@click="patch({ on: !manual.on, selected: !manual.on ? 'body' : null })"
>
<span>{{ t('manual.on') }}</span>
<span
class="relative h-5 w-9 shrink-0 rounded-full transition"
:class="manual.on ? 'bg-[var(--ink)]' : 'bg-[var(--line)]'"
<div class="flex items-center justify-between">
<h2
class="text-sm font-semibold transition"
:class="!manual.on && 'text-[var(--muted)]'"
>
{{ t('manual.title') }}
</h2>
<button
type="button"
class="relative h-5 w-9 shrink-0 cursor-pointer rounded-full transition"
:class="manual.on ? 'bg-[var(--ink)]' : 'bg-[var(--line)] hover:bg-[var(--muted)]'"
:aria-label="t('manual.title')"
:aria-pressed="manual.on"
@click="patch({ on: !manual.on, selected: !manual.on ? 'body' : null })"
>
<span
class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition"
:class="manual.on && 'translate-x-4'"
/>
</span>
</button>
</button>
</div>
<h2 class="mt-5 text-sm font-semibold">{{ t('panel.shape') }}</h2>
<div class="mt-2 grid grid-cols-4 gap-1.5">
@@ -56,6 +214,7 @@ function patch(next: Partial<ManualState>) {
:selected="id === shape"
:shape="id"
:color="color"
:paper="oeil || '#f9f9f9'"
:state="expression"
:active="active"
@click="shape = id"
@@ -71,6 +230,7 @@ function patch(next: Partial<ManualState>) {
:selected="id === expression"
:shape="shape"
:color="color"
:paper="oeil || '#f9f9f9'"
:state="id"
:active="active"
@click="expression = id"
@@ -85,18 +245,238 @@ function patch(next: Partial<ManualState>) {
type="button"
class="flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
:class="
id === color ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
estNuancier(id) ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
"
:aria-label="id"
:aria-pressed="id === color"
:aria-pressed="estNuancier(id)"
:title="id"
@click="color = id"
@click="choisitNuancier(id)"
>
<span
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
:style="{ background: colorFill(id) }"
/>
</button>
<label
class="relative flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
:class="
estPerso ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
"
:title="t('panel.custom')"
>
<span
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
:style="{ background: estPerso ? flatHex : PERSO_FOND }"
/>
<input
type="color"
class="absolute inset-0 cursor-pointer opacity-0"
:value="flatHex"
:aria-label="t('panel.custom')"
@input="saisitPerso"
/>
</label>
</div>
<div class="mt-2.5 flex rounded-full bg-black/[0.04] p-0.5">
<button
type="button"
class="flex-1 cursor-pointer rounded-full px-1 py-1 text-xs whitespace-nowrap transition"
:class="
activeSpec.kind === 'flat'
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
: 'text-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="activeSpec.kind === 'flat'"
@click="choisitUni"
>
{{ t('panel.flat') }}
</button>
<button
type="button"
class="flex-1 cursor-pointer rounded-full px-1 py-1 text-xs whitespace-nowrap transition"
:class="
activeSpec.kind === 'grad'
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
: 'text-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="activeSpec.kind === 'grad'"
@click="choisitDegrade"
>
{{ t('panel.linear') }}
</button>
<button
type="button"
class="flex-1 cursor-pointer rounded-full px-1 py-1 text-xs whitespace-nowrap transition"
:class="
activeSpec.kind === 'radial'
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
: 'text-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="activeSpec.kind === 'radial'"
@click="choisitRadial"
>
{{ t('panel.radial') }}
</button>
</div>
<div
v-if="activeSpec.kind === 'grad' || activeSpec.kind === 'radial'"
class="mt-2 flex items-center gap-2"
>
<div class="teinte-barre min-w-0 flex-1" :style="{ background: apercu }">
<label
v-if="activeSpec.kind === 'grad'"
class="photo-puce"
:title="gradFrom.toUpperCase()"
:style="{ background: gradFrom }"
>
<input
type="color"
:value="gradFrom"
:aria-label="t('panel.gradFrom')"
@input="saisit('from', $event)"
/>
</label>
<button
v-if="activeSpec.kind === 'grad'"
type="button"
class="absolute top-1/2 left-1/2 z-10 grid h-6 w-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-white text-[var(--muted)] shadow-sm ring-1 ring-black/10 transition hover:text-[var(--ink)]"
:aria-label="t('panel.swap')"
:title="t('panel.swap')"
@click="echangeDegrade"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M8 3 4 7l4 4" />
<path d="M4 7h16" />
<path d="m16 21 4-4-4-4" />
<path d="M20 17H4" />
</svg>
</button>
<label
v-if="activeSpec.kind === 'radial'"
class="photo-puce photo-puce--coeur"
:title="gradFrom.toUpperCase()"
:style="{ background: gradFrom }"
>
<input
type="color"
:value="gradFrom"
:aria-label="t('panel.gradInner')"
@input="saisit('from', $event)"
/>
</label>
<label
class="photo-puce photo-puce--fin"
:title="gradTo.toUpperCase()"
:style="{ background: gradTo }"
>
<input
type="color"
:value="gradTo"
:aria-label="activeSpec.kind === 'radial' ? t('panel.gradOuter') : t('panel.gradTo')"
@input="saisit('to', $event)"
/>
</label>
</div>
<button
v-if="activeSpec.kind === 'grad'"
type="button"
class="teinte-cadran"
:style="{ background: apercu }"
:aria-label="t('panel.angle')"
:title="`${gradAngle}°`"
role="slider"
:aria-valuenow="gradAngle"
aria-valuemin="0"
aria-valuemax="359"
@pointerdown="cadranPointe"
@pointermove="cadranGlisse"
@keydown="cadranClavier"
>
<span
class="pointer-events-none absolute inset-0"
:style="{ transform: `rotate(${gradAngle}deg)` }"
>
<i class="teinte-cadran-aiguille" aria-hidden="true" />
</span>
</button>
<button
v-else
type="button"
class="grid h-9 w-9 shrink-0 cursor-pointer place-items-center rounded-full bg-white text-[var(--muted)] shadow-sm ring-1 ring-black/10 transition hover:text-[var(--ink)]"
:aria-label="t('panel.swapRadial')"
:title="t('panel.swapRadial')"
@click="echangeDegrade"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M8 3 4 7l4 4" />
<path d="M4 7h16" />
<path d="m16 21 4-4-4-4" />
<path d="M20 17H4" />
</svg>
</button>
</div>
<div class="mt-5 flex items-center gap-1.5">
<h2 class="flex-1 text-sm font-semibold">{{ t('panel.eye') }}</h2>
<button
v-if="oeil"
type="button"
class="grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded-full text-[var(--muted)] transition hover:bg-black/5 hover:text-[var(--ink)]"
:aria-label="t('panel.eyeReset')"
:title="t('panel.eyeReset')"
@click="reinitialiseOeil"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
<label
class="teinte-oeil"
:class="oeil && 'est-teint'"
:title="oeilHex.toUpperCase()"
>
<span class="teinte-oeil-blanc" :style="{ background: oeilHex }" />
<span class="teinte-oeil-pupille" />
<input
type="color"
:value="oeilHex"
:aria-label="t('panel.eye')"
@input="saisit('oeil', $event)"
/>
</label>
</div>
</div>
</template>
+1 -1
View File
@@ -94,7 +94,7 @@ defineExpose({
<div class="relative inline-block aspect-square max-w-full" :style="{ width: `${props.size}px` }">
<svg
ref="svg"
class="absolute inset-0 h-full w-full overflow-visible"
class="absolute inset-0 h-full w-full max-w-full max-h-full overflow-visible"
:width="props.size"
:height="props.size"
role="img"
+27 -10
View File
@@ -46,11 +46,15 @@ export type HeroHandle = {
} | null
}
const props = defineProps<{
manual: ManualState
hero: HeroHandle | null
shape?: string
}>()
const props = withDefaults(
defineProps<{
manual: ManualState
hero: HeroHandle | null
shape?: string
echelle?: number
}>(),
{ echelle: 1 }
)
const emit = defineEmits<{
'update:pose': [HeadPose]
@@ -79,6 +83,7 @@ const rings = computed(() => {
const activeAxis = ref<'x' | 'y' | 'z' | 'view' | null>(null)
let wireGroup: SVGGElement | null = null
const eyeShown: Record<'left' | 'right', boolean | null> = { left: null, right: null }
type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate'
@@ -165,15 +170,20 @@ const syncOverlay = () => {
const host = sceneSvg()
const layer = manualLayer.value
if (!host || !layer) return
/*
* Les rects ecran incluent l'echelle photo du sujet ; l'overlay vit
* lui-meme dans le sujet mis a l'echelle — diviser par `echelle` rend
* sa boite CSS pre-transform, sinon la geometry serait comptee deux fois.
*/
const hostBox = host.getBoundingClientRect()
const layerBox = layer.getBoundingClientRect()
overlayStyle.value = {
left: `${(hostBox.left - layerBox.left).toFixed(2)}px`,
top: `${(hostBox.top - layerBox.top).toFixed(2)}px`,
left: `${((hostBox.left - layerBox.left) / props.echelle).toFixed(2)}px`,
top: `${((hostBox.top - layerBox.top) / props.echelle).toFixed(2)}px`,
right: 'auto',
bottom: 'auto',
width: `${hostBox.width.toFixed(2)}px`,
height: `${hostBox.height.toFixed(2)}px`
width: `${(hostBox.width / props.echelle).toFixed(2)}px`,
height: `${(hostBox.height / props.echelle).toFixed(2)}px`
}
}
@@ -228,6 +238,13 @@ const readEye = (tune: EyeSide, side: 'left' | 'right'): EyeFrame => {
project(localX, localY)
)
const center = xy(project(0, 0))
// Same visibility hysteresis as the engine eyes: drag jitter around the
// silhouette boundary must not pop the editor handles in and out.
const zAvg = outline.reduce((sum, point) => sum + point[2], 0) / Math.max(outline.length, 1)
let shown = eyeShown[side] ?? zAvg > 0
if (shown && zAvg < -0.006) shown = false
if (!shown && zAvg > 0.004) shown = true
eyeShown[side] = shown
const widthHandle = xy(project(width / 2 + 9, 0))
const heightHandle = xy(project(0, -height / 2 - 9))
const rotateHandle = xy(project(0, -height / 2 - 30))
@@ -235,7 +252,7 @@ const readEye = (tune: EyeSide, side: 'left' | 'right'): EyeFrame => {
const plusX = Math.hypot(widthHandle[0] - center[0], widthHandle[1] - center[1])
const minusY = -Math.hypot(heightHandle[0] - center[0], heightHandle[1] - center[1])
return {
visible: outline.reduce((sum, point) => sum + point[2], 0) > 0,
visible: shown,
available: true,
center,
widthAxis: unitVector(center, widthHandle),
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { t } from '@/i18n'
import {
fondCss,
normaliseComposition,
ANGLE_DEFAUT,
type CompositionPhoto,
type FondPhoto
} from '@/ui/photo'
/**
* Cadre vivant du mode photo. Reste TOUJOURS monte (display:contents quand
* inactif) : le slot contient l'AvatarStage du heros, qui ne doit jamais etre
* reparente — un remontage relancerait le moteur replica et perdrait la pose.
*
* Math WYSIWYG : le cadre fait 300 unites, donc translate(x/3 %) en CSS egale
* translate(x) dans le SVG exporte par `svgPhoto`.
*/
const props = withDefaults(
defineProps<{
active?: boolean
fond?: FondPhoto
couleurDe?: string
couleurA?: string
angle?: number
outil?: 'cadre' | 'pose'
flash?: number
}>(),
{
active: false,
fond: 'transparent',
couleurDe: '#F5F7FC',
couleurA: '#C9D5FF',
angle: ANGLE_DEFAUT,
outil: 'cadre',
flash: 0
}
)
const composition = defineModel<CompositionPhoto>('composition', { required: true })
const cadre = ref<HTMLDivElement | null>(null)
const glisse = ref<{ clientX: number; clientY: number; x: number; y: number } | null>(null)
function patch(partiel: Partial<CompositionPhoto>) {
composition.value = normaliseComposition({ ...composition.value, ...partiel })
}
function zoome(e: WheelEvent) {
e.preventDefault()
e.stopPropagation()
if (glisse.value) return
patch({ scale: composition.value.scale * Math.exp(-e.deltaY * 0.0015) })
}
watch(
[() => props.active, () => props.outil],
([actif, outil]) => {
const node = cadre.value
if (!node) return
node.removeEventListener('wheel', zoome)
if (actif && outil === 'cadre') node.addEventListener('wheel', zoome, { passive: false })
},
{ immediate: true, flush: 'post' }
)
onBeforeUnmount(() => cadre.value?.removeEventListener('wheel', zoome))
function debute(e: PointerEvent) {
if (e.button !== 0) return
e.preventDefault()
glisse.value = {
clientX: e.clientX,
clientY: e.clientY,
x: composition.value.x,
y: composition.value.y
}
const cible = e.currentTarget as HTMLElement
cible.dataset.dragging = ''
cible.setPointerCapture(e.pointerId)
}
function glit(e: PointerEvent) {
if (!glisse.value) return
const cible = e.currentTarget as HTMLElement
if (!cible.hasPointerCapture(e.pointerId)) return
const boite = cible.getBoundingClientRect()
const unitesParPixel = 300 / Math.max(boite.width, 1)
patch({
x: glisse.value.x + (e.clientX - glisse.value.clientX) * unitesParPixel,
y: glisse.value.y + (e.clientY - glisse.value.clientY) * unitesParPixel
})
}
function arrete(e: PointerEvent) {
if (!glisse.value) return
glisse.value = null
delete (e.currentTarget as HTMLElement).dataset.dragging
}
function pousse(e: KeyboardEvent) {
const pas = e.shiftKey ? 10 : 2
if (e.key === 'ArrowLeft') patch({ x: composition.value.x - pas })
else if (e.key === 'ArrowRight') patch({ x: composition.value.x + pas })
else if (e.key === 'ArrowUp') patch({ y: composition.value.y - pas })
else if (e.key === 'ArrowDown') patch({ y: composition.value.y + pas })
else if (e.key === '+' || e.key === '=') patch({ scale: composition.value.scale + 0.05 })
else if (e.key === '-') patch({ scale: composition.value.scale - 0.05 })
else return
e.preventDefault()
}
</script>
<template>
<div
ref="cadre"
class="photo-cadre-hote"
:class="[
active && 'photo-cadre',
active && fond === 'transparent' && 'est-transparent',
active && outil === 'cadre' && 'est-cadre'
]"
:style="
active && {
'--photo-coins': `${composition.cornerRadius}%`,
'--photo-echelle': composition.scale,
background: fondCss(fond, couleurDe, couleurA, angle)
}
"
>
<div
class="photo-sujet"
:style="
active && {
transform: `translate(${composition.x / 3}%, ${composition.y / 3}%) scale(${composition.scale})`
}
"
>
<slot />
</div>
<div
v-if="active && outil === 'cadre'"
class="photo-interaction"
role="application"
tabindex="0"
:aria-label="t('photo.frameAria')"
@pointerdown="debute"
@pointermove="glit"
@pointerup="arrete"
@pointercancel="arrete"
@keydown="pousse"
/>
<div v-if="active && flash > 0" :key="flash" class="photo-flash" aria-hidden="true" />
</div>
</template>
+739
View File
@@ -0,0 +1,739 @@
<script setup lang="ts">
import BotTile from '@/components/BotTile.vue'
import { stateName, t, type Cle } from '@/i18n'
import { COLOR_IDS, EXPRESSION_IDS, colorFill, colorHex, inkHex } from '@/replica/catalog'
import { angleDepuisPointeur, PAS_CADRAN, tourAngle } from '@/ui/cadran'
import {
fondCss,
paletteAleatoire,
type CompositionPhoto,
type FondPhoto
} from '@/ui/photo'
import {
clampEyes,
clampOffset,
clampPose,
resetAll,
updateEye,
type BodyOffset,
type EyeSide,
type EyeTune,
type ManualState
} from '@/ui/pose/model'
import type { HeadPose } from '@/ui/pose/math'
/**
* Reglages du mode photo : pose chiffree, cadrage, fond, expression et
* export. Meme rythme que le personnalisateur (titre + contenu, pas de
* cartes). Presentationnelle — la logique vit dans `App.vue` et `ui/photo.ts`.
*/
defineProps<{ active?: boolean; manuel?: boolean }>()
const composition = defineModel<CompositionPhoto>('composition', { required: true })
const fond = defineModel<FondPhoto>('fond', { required: true })
const couleurDe = defineModel<string>('couleurDe', { required: true })
const couleurA = defineModel<string>('couleurA', { required: true })
const angle = defineModel<number>('angle', { required: true })
const expression = defineModel<string>('expression', { required: true })
const format = defineModel<'png' | 'svg'>('format', { required: true })
const taille = defineModel<number>('taille', { required: true })
const shape = defineModel<string>('shape', { required: true })
const color = defineModel<string>('color', { required: true })
const manual = defineModel<ManualState>('manual', { required: true })
type Cote = 'left' | 'right'
const COTES: Cote[] = ['left', 'right']
const DIMENSIONS = [
{ cle: 'width', libelle: 'manual.width', min: 10, max: 110, pas: 1 },
{ cle: 'height', libelle: 'manual.height', min: 10, max: 110, pas: 1 },
{ cle: 'size', libelle: 'manual.size', min: 0.35, max: 2.2, pas: 0.05 }
] as const
const FONDS: FondPhoto[] = ['transparent', 'solid', 'linear', 'radial']
const TAILLES = [512, 1024, 2048]
const PERSO_FOND =
'conic-gradient(from 0.5turn, #e02135, #e08600, #009957, #1aa8c4, #0e74e0, #804ee0, #e02a88, #e02135)'
const LIBELLES_FONDS: Record<FondPhoto, Cle> = {
transparent: 'photo.fond_transparent',
solid: 'photo.fond_solid',
linear: 'photo.fond_linear',
radial: 'photo.fond_radial'
}
const num = (v: string) => Number(v)
function estNuancier(id: string) {
return colorHex(id).toLowerCase() === couleurDe.value.toLowerCase()
}
function estPerso() {
return COLOR_IDS.every((id) => colorHex(id).toLowerCase() !== couleurDe.value.toLowerCase())
}
function choisitUni(hex: string) {
couleurDe.value = hex
couleurA.value = hex
}
function choisitNuancier(id: string) {
choisitUni(colorHex(id))
}
function saisitPerso(e: Event) {
choisitUni((e.target as HTMLInputElement).value)
}
/** Apercu vivant du bouton de fond : le vrai degrade aux couleurs courantes. */
function styleFond(id: FondPhoto) {
const css = fondCss(id, couleurDe.value, couleurA.value, angle.value)
return css ? { background: css } : undefined
}
function patch(partiel: Partial<CompositionPhoto>) {
composition.value = { ...composition.value, ...partiel }
}
function recentre() {
patch({ x: 0, y: 0, scale: 1 })
}
/* ---- pose chiffree : applique le reglage manuel, avec bornes ---- */
function posePatch(partiel: Partial<HeadPose>) {
manual.value = { ...manual.value, pose: clampPose({ ...manual.value.pose, ...partiel }) }
}
function offsetPatch(partiel: Partial<BodyOffset>) {
manual.value = { ...manual.value, offset: clampOffset({ ...manual.value.offset, ...partiel }) }
}
function yeuxPatch(yeux: EyeTune) {
manual.value = { ...manual.value, eyes: clampEyes(yeux) }
}
function oeil(cote: Cote, partiel: Partial<EyeSide>) {
yeuxPatch(updateEye(manual.value.eyes, cote, partiel, manual.value.links))
}
function angleOeil(cote: Cote, valeur: number) {
const autre: Cote = cote === 'left' ? 'right' : 'left'
const yeux = {
left: { ...manual.value.eyes.left },
right: { ...manual.value.eyes.right },
spacing: manual.value.eyes.spacing
}
yeux[cote].angle = valeur
if (manual.value.links.rotation) yeux[autre].angle = -valeur
yeuxPatch(yeux)
}
function basculeLien(cle: 'width' | 'height' | 'size' | 'rotation') {
manual.value = {
...manual.value,
links: { ...manual.value.links, [cle]: !manual.value.links[cle] }
}
}
function reinitialisePose() {
manual.value = resetAll(manual.value)
}
/** Le champ pris met le feu sur la piece concernee, comme les gizmos. */
function surligne(partie: 'body' | 'left' | 'right' | null) {
if (partie && manual.value.selected !== partie) {
manual.value = { ...manual.value, selected: partie }
} else if (!partie && manual.value.selected) {
manual.value = { ...manual.value, selected: null }
}
}
function aleatoire() {
const style = fond.value === 'transparent' ? 'solid' : fond.value
const palette = paletteAleatoire(style, inkHex(color.value), {
couleurDe: couleurDe.value,
couleurA: couleurA.value
})
couleurDe.value = palette.couleurDe
couleurA.value = palette.couleurA
if (fond.value === 'transparent') fond.value = 'solid'
}
function echangeCouleurs() {
;[couleurDe.value, couleurA.value] = [couleurA.value, couleurDe.value]
}
function cadranPointe(e: PointerEvent) {
e.preventDefault()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
angle.value = tourAngle(angleDepuisPointeur(el, e))
}
function cadranGlisse(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
angle.value = tourAngle(angleDepuisPointeur(el, e))
}
function cadranClavier(e: KeyboardEvent) {
const pas = PAS_CADRAN[e.key]
if (pas == null) return
e.preventDefault()
angle.value = tourAngle(angle.value + pas)
}
</script>
<template>
<div class="flex flex-col gap-5">
<section v-if="manuel">
<div class="flex items-center justify-between gap-2">
<h2 class="text-sm font-semibold">{{ t('photo.poseTitle') }}</h2>
<button type="button" class="photo-bouton" @click="reinitialisePose">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{{ t('photo.poseReset') }}
</button>
</div>
<h3 class="mt-2.5 text-xs font-medium text-[var(--muted)]">{{ t('manual.head') }}</h3>
<div class="mt-1 grid grid-cols-3 gap-1.5">
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.axisTilt') }}</span>
<input
type="number"
class="mt-0.5"
:value="manual.pose.tilt"
:step="1"
@focus="surligne('body')"
@blur="surligne(null)"
@change="posePatch({ tilt: num(($event.target as HTMLInputElement).value) })"
/>
</label>
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.axisTurn') }}</span>
<input
type="number"
class="mt-0.5"
:value="manual.pose.turn"
:step="1"
@focus="surligne('body')"
@blur="surligne(null)"
@change="posePatch({ turn: num(($event.target as HTMLInputElement).value) })"
/>
</label>
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.axisRoll') }}</span>
<input
type="number"
class="mt-0.5"
:value="manual.pose.roll"
:step="1"
@focus="surligne('body')"
@blur="surligne(null)"
@change="posePatch({ roll: num(($event.target as HTMLInputElement).value) })"
/>
</label>
</div>
<h3 class="mt-2.5 text-xs font-medium text-[var(--muted)]">{{ t('manual.body') }}</h3>
<div class="mt-1 grid grid-cols-3 gap-1.5">
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.posH') }}</span>
<input
type="number"
class="mt-0.5"
:value="manual.offset.tx"
:min="-90"
:max="90"
:step="1"
@focus="surligne('body')"
@blur="surligne(null)"
@change="offsetPatch({ tx: num(($event.target as HTMLInputElement).value) })"
/>
</label>
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.posV') }}</span>
<input
type="number"
class="mt-0.5"
:value="manual.offset.ty"
:min="-90"
:max="90"
:step="1"
@focus="surligne('body')"
@blur="surligne(null)"
@change="offsetPatch({ ty: num(($event.target as HTMLInputElement).value) })"
/>
</label>
<label class="photo-champ">
<span class="photo-etiquette">{{ t('manual.spin') }}</span>
<input
type="number"
class="mt-0.5"
:value="manual.offset.spin"
:min="-180"
:max="180"
:step="1"
@focus="surligne('body')"
@blur="surligne(null)"
@change="offsetPatch({ spin: num(($event.target as HTMLInputElement).value) })"
/>
</label>
</div>
</section>
<section v-if="manuel">
<h2 class="text-sm font-semibold">{{ t('manual.eyes') }}</h2>
<div class="mt-2 grid grid-cols-[2.75rem_auto_1fr_1fr] items-center gap-x-1.5 gap-y-1">
<span class="col-span-2" />
<span class="photo-etiquette text-center">{{ t('photo.eyeLeft') }}</span>
<span class="photo-etiquette text-center">{{ t('photo.eyeRight') }}</span>
<template v-for="d in DIMENSIONS" :key="d.cle">
<span class="photo-etiquette">{{ t(d.libelle) }}</span>
<button
type="button"
class="photo-lien"
:aria-pressed="manual.links[d.cle]"
:aria-label="t('photo.link')"
:title="t('photo.link')"
@click="basculeLien(d.cle)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<template v-if="manual.links[d.cle]">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</template>
<template v-else>
<path d="m13.5 7.5 1.9-1.9a4.24 4.24 0 0 1 6 6l-1.9 1.9" />
<path d="m10.5 16.5-1.9 1.9a4.24 4.24 0 0 1-6-6l1.9-1.9" />
</template>
</svg>
</button>
<input
type="number"
:value="manual.eyes.left[d.cle]"
:min="d.min"
:max="d.max"
:step="d.pas"
:aria-label="`${t('photo.eyeLeft')}·${t(d.libelle)}`"
@focus="surligne('left')"
@blur="surligne(null)"
@change="oeil('left', { [d.cle]: num(($event.target as HTMLInputElement).value) })"
/>
<input
type="number"
:value="manual.eyes.right[d.cle]"
:min="d.min"
:max="d.max"
:step="d.pas"
:aria-label="`${t('photo.eyeRight')}·${t(d.libelle)}`"
@focus="surligne('right')"
@blur="surligne(null)"
@change="oeil('right', { [d.cle]: num(($event.target as HTMLInputElement).value) })"
/>
</template>
</div>
<h3 class="mt-2.5 text-xs font-medium text-[var(--muted)]">{{ t('photo.position') }}</h3>
<div class="mt-1 grid grid-cols-[2.75rem_1fr_1fr_1fr] items-center gap-x-1.5 gap-y-1">
<span />
<span class="photo-etiquette text-center">{{ t('photo.posH') }}</span>
<span class="photo-etiquette text-center">{{ t('photo.posV') }}</span>
<span class="photo-etiquette text-center">{{ t('photo.angle') }}</span>
<template v-for="cote in COTES" :key="cote">
<span class="photo-etiquette">{{ t(cote === 'left' ? 'photo.eyeLeft' : 'photo.eyeRight') }}</span>
<input
type="number"
:value="manual.eyes[cote].x"
:min="-48"
:max="48"
:step="1"
:aria-label="`${t(cote === 'left' ? 'photo.eyeLeft' : 'photo.eyeRight')}·${t('photo.posH')}`"
@focus="surligne(cote)"
@blur="surligne(null)"
@change="oeil(cote, { x: num(($event.target as HTMLInputElement).value) })"
/>
<input
type="number"
:value="manual.eyes[cote].y"
:min="-48"
:max="48"
:step="1"
:aria-label="`${t(cote === 'left' ? 'photo.eyeLeft' : 'photo.eyeRight')}·${t('photo.posV')}`"
@focus="surligne(cote)"
@blur="surligne(null)"
@change="oeil(cote, { y: num(($event.target as HTMLInputElement).value) })"
/>
<input
type="number"
:value="manual.eyes[cote].angle"
:min="-180"
:max="180"
:step="1"
:aria-label="`${t(cote === 'left' ? 'photo.eyeLeft' : 'photo.eyeRight')}·${t('photo.angle')}`"
@focus="surligne(cote)"
@blur="surligne(null)"
@change="angleOeil(cote, num(($event.target as HTMLInputElement).value))"
/>
</template>
<span class="photo-etiquette">{{ t('manual.spacing') }}</span>
<input
type="number"
class="col-span-3"
:value="manual.eyes.spacing"
:min="0"
:max="150"
:step="1"
:aria-label="t('manual.spacing')"
@change="yeuxPatch({ ...manual.eyes, spacing: num(($event.target as HTMLInputElement).value) })"
/>
</div>
</section>
<section>
<div class="flex items-center justify-between gap-2">
<h2 class="text-sm font-semibold">{{ t('photo.composition') }}</h2>
<button type="button" class="photo-bouton" @click="recentre">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>
{{ t('photo.recentre') }}
</button>
</div>
<div class="mt-2 grid grid-cols-2 gap-1.5">
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.posH') }}</span>
<input
type="number"
class="mt-0.5"
:value="composition.x"
:min="-180"
:max="180"
:step="1"
@change="patch({ x: num(($event.target as HTMLInputElement).value) })"
/>
</label>
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.posV') }}</span>
<input
type="number"
class="mt-0.5"
:value="composition.y"
:min="-180"
:max="180"
:step="1"
@change="patch({ y: num(($event.target as HTMLInputElement).value) })"
/>
</label>
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.zoom') }}</span>
<input
type="number"
class="mt-0.5"
:value="Math.round(composition.scale * 100)"
:min="40"
:max="300"
:step="5"
@change="patch({ scale: num(($event.target as HTMLInputElement).value) / 100 })"
/>
</label>
<label class="photo-champ">
<span class="photo-etiquette">{{ t('photo.corner') }}</span>
<input
type="number"
class="mt-0.5"
:value="composition.cornerRadius"
:min="0"
:max="50"
:step="1"
@change="patch({ cornerRadius: num(($event.target as HTMLInputElement).value) })"
/>
</label>
</div>
</section>
<section>
<div class="flex items-center justify-between gap-2">
<h2 class="text-sm font-semibold">{{ t('photo.background') }}</h2>
<button type="button" class="photo-bouton" @click="aleatoire">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.8-1.1 2-1.7 3.3-1.7H22" />
<path d="m18 2 4 4-4 4" />
<path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2" />
<path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8" />
<path d="m18 14 4 4-4 4" />
</svg>
{{ t('photo.random') }}
</button>
</div>
<div class="mt-2 flex rounded-full bg-black/[0.04] p-0.5">
<button
v-for="id in FONDS"
:key="id"
type="button"
class="flex-1 cursor-pointer rounded-full px-1 py-1 text-xs whitespace-nowrap transition"
:class="
fond === id
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
: 'text-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="fond === id"
@click="fond = id"
>
{{ t(LIBELLES_FONDS[id]) }}
</button>
</div>
<div v-if="fond === 'solid'" class="mt-2 grid grid-cols-6 gap-1.5">
<button
v-for="id in COLOR_IDS"
:key="id"
type="button"
class="flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
:class="
estNuancier(id) ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
"
:aria-label="id"
:aria-pressed="estNuancier(id)"
:title="id"
@click="choisitNuancier(id)"
>
<span
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
:style="{ background: colorFill(id) }"
/>
</button>
<label
class="relative flex aspect-square cursor-pointer items-center justify-center rounded-full border-2 transition"
:class="
estPerso() ? 'border-[var(--ink)]' : 'border-transparent hover:border-[var(--line)]'
"
:title="t('panel.custom')"
>
<span
class="block h-[78%] w-[78%] rounded-full ring-1 ring-black/10 ring-inset"
:style="{ background: estPerso() ? couleurDe : PERSO_FOND }"
/>
<input
type="color"
class="absolute inset-0 cursor-pointer opacity-0"
:value="couleurDe"
:aria-label="t('panel.custom')"
@input="saisitPerso"
/>
</label>
</div>
<div
v-if="fond === 'linear' || fond === 'radial'"
class="mt-2 flex items-center gap-2"
>
<div class="teinte-barre min-w-0 flex-1" :style="styleFond(fond)">
<label
v-if="fond === 'linear'"
class="photo-puce"
:title="couleurDe.toUpperCase()"
:style="{ background: couleurDe }"
>
<input
type="color"
:value="couleurDe"
:aria-label="t('photo.colorStart')"
@input="couleurDe = ($event.target as HTMLInputElement).value"
/>
</label>
<button
v-if="fond === 'linear'"
type="button"
class="absolute top-1/2 left-1/2 z-10 grid h-6 w-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-white text-[var(--muted)] shadow-sm ring-1 ring-black/10 transition hover:text-[var(--ink)]"
:aria-label="t('panel.swap')"
:title="t('panel.swap')"
@click="echangeCouleurs"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M8 3 4 7l4 4" />
<path d="M4 7h16" />
<path d="m16 21 4-4-4-4" />
<path d="M20 17H4" />
</svg>
</button>
<label
v-if="fond === 'radial'"
class="photo-puce photo-puce--coeur"
:title="couleurDe.toUpperCase()"
:style="{ background: couleurDe }"
>
<input
type="color"
:value="couleurDe"
:aria-label="t('photo.colorInner')"
@input="couleurDe = ($event.target as HTMLInputElement).value"
/>
</label>
<label
class="photo-puce photo-puce--fin"
:title="couleurA.toUpperCase()"
:style="{ background: couleurA }"
>
<input
type="color"
:value="couleurA"
:aria-label="fond === 'radial' ? t('photo.colorOuter') : t('photo.colorEnd')"
@input="couleurA = ($event.target as HTMLInputElement).value"
/>
</label>
</div>
<button
v-if="fond === 'linear'"
type="button"
class="teinte-cadran"
:style="styleFond(fond)"
:aria-label="t('photo.direction')"
:title="`${angle}°`"
role="slider"
:aria-valuenow="angle"
aria-valuemin="0"
aria-valuemax="359"
@pointerdown="cadranPointe"
@pointermove="cadranGlisse"
@keydown="cadranClavier"
>
<span
class="pointer-events-none absolute inset-0"
:style="{ transform: `rotate(${angle}deg)` }"
>
<i class="teinte-cadran-aiguille" aria-hidden="true" />
</span>
</button>
<button
v-else-if="fond === 'radial'"
type="button"
class="grid h-9 w-9 shrink-0 cursor-pointer place-items-center rounded-full bg-white text-[var(--muted)] shadow-sm ring-1 ring-black/10 transition hover:text-[var(--ink)]"
:aria-label="t('panel.swapRadial')"
:title="t('panel.swapRadial')"
@click="echangeCouleurs"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M8 3 4 7l4 4" />
<path d="M4 7h16" />
<path d="m16 21 4-4-4-4" />
<path d="M20 17H4" />
</svg>
</button>
</div>
</section>
<section>
<h2 class="text-sm font-semibold">{{ t('photo.expression') }}</h2>
<div class="mt-2 grid grid-cols-4 gap-1.5">
<BotTile
v-for="id in EXPRESSION_IDS"
:key="id"
:label="stateName(id)"
:selected="!manuel && id === expression"
:shape="shape"
:color="color"
:state="id"
:active="active"
@click="expression = id"
/>
</div>
</section>
<section>
<h2 class="text-sm font-semibold">{{ t('photo.exportTitle') }}</h2>
<div class="mt-2 flex rounded-full bg-black/[0.04] p-0.5">
<button
v-for="id in ['png', 'svg'] as const"
:key="id"
type="button"
class="flex-1 cursor-pointer rounded-full py-1 text-xs uppercase transition"
:class="
id === format
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
: 'text-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="id === format"
@click="format = id"
>
{{ id }}
</button>
</div>
<div class="mt-1.5 flex rounded-full bg-black/[0.04] p-0.5">
<button
v-for="n in TAILLES"
:key="n"
type="button"
class="flex-1 cursor-pointer rounded-full py-1 text-xs transition"
:class="
n === taille
? 'bg-[var(--ink)] font-medium text-[var(--paper)]'
: 'text-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="n === taille"
@click="taille = n"
>
{{ t('photo.pixels', { n }) }}
</button>
</div>
</section>
</div>
</template>
+5 -3
View File
@@ -368,7 +368,7 @@ function onRulerMove(e: PointerEvent) {
<li
v-for="(b, i) in blocks"
:key="`${i}-${b.state}`"
class="group relative shrink-0 pr-1"
class="group relative shrink-0 pr-1.5"
:class="lifted(i) ? 'z-20' : 'transition-transform duration-150 ease-out'"
:style="{
width: `${b.duration * scale}px`,
@@ -422,10 +422,12 @@ function onRulerMove(e: PointerEvent) {
</span>
</button>
<!-- poignee de duree : bouton a part entiere, donc utilisable au clavier -->
<!-- poignee de duree : bouton a part entiere, donc utilisable au clavier.
`right-px` + gouttiere de 6px : la ligne garde 1px d'air de chaque
cote, elle ne touche plus le ring de la carte selectionnee -->
<button
type="button"
class="absolute inset-y-2 right-0.5 w-1 cursor-ew-resize rounded-full bg-[var(--muted)] opacity-0 transition group-hover:opacity-60 hover:opacity-100! focus-visible:opacity-100"
class="absolute inset-y-2 right-px w-1 cursor-ew-resize rounded-full bg-[var(--muted)] opacity-0 transition group-hover:opacity-60 hover:opacity-100! focus-visible:opacity-100"
:aria-label="
t('timeline.blockDurationAria', {
state: label(i),
+56 -2
View File
@@ -18,6 +18,20 @@ const en: typeof zh = {
shape: 'Shape',
expression: 'Expression',
color: 'Colour',
custom: 'Custom colour',
flat: 'Solid',
gradient: 'Gradient',
linear: 'Linear gradient',
radial: 'Radial gradient',
gradFrom: 'Gradient start',
gradTo: 'Gradient end',
gradInner: 'Inner colour',
gradOuter: 'Outer colour',
swap: 'Swap gradient ends',
swapRadial: 'Swap inner and outer colours',
angle: 'Gradient angle',
eye: 'Eyes',
eyeReset: 'Reset eye colour',
group_lifecycle: 'Lifecycle',
group_reactions: 'Reactions',
group_agent: 'Agent',
@@ -177,8 +191,6 @@ const en: typeof zh = {
},
manual: {
title: 'Manual adjust',
help: 'When on, drag the body to turn it, click an eye for L/H/S/R/E, or drag the rings at the top right.',
on: 'Enable manual adjust',
reset: 'Reset',
head: 'Head rotation',
body: 'Body',
@@ -190,6 +202,48 @@ const en: typeof zh = {
spacing: 'Gap',
gizmo: 'Rotation gizmo',
plane: 'Move in the picture plane'
},
photo: {
open: 'Photo mode',
exit: 'Exit photo',
frameAria: 'Photo frame. Drag to move the character, scroll to zoom, arrow keys to nudge.',
toolFrame: 'Frame',
toolPose: 'Pose',
reset: 'Reset',
poseTitle: 'Pose',
poseReset: 'Reset',
axisTilt: 'Tilt',
axisTurn: 'Turn',
axisRoll: 'Roll',
posH: 'Horizontal',
posV: 'Vertical',
link: 'Link',
eyeLeft: 'Left eye',
eyeRight: 'Right eye',
angle: 'Angle',
position: 'Position and spacing',
composition: 'Composition',
positionX: 'Position X',
positionY: 'Position Y',
zoom: 'Zoom',
corner: 'Corner radius',
recentre: 'Recentre framing',
background: 'Background',
fond_transparent: 'Transparent',
fond_solid: 'Solid',
fond_linear: 'Linear gradient',
fond_radial: 'Radial gradient',
color: 'Colour',
colorStart: 'Start',
colorEnd: 'End',
colorInner: 'Inner',
colorOuter: 'Outer',
direction: 'Direction',
random: 'Random palette',
expression: 'Expression',
exportTitle: 'Export',
pixels: '{n} px',
take: 'Take photo'
}
}
+56 -2
View File
@@ -16,6 +16,20 @@ const zh = {
shape: '形体',
expression: '表情',
color: '颜色',
custom: '自定义颜色',
flat: '单色',
gradient: '渐变',
linear: '线性渐变',
radial: '径向渐变',
gradFrom: '渐变起始色',
gradTo: '渐变结束色',
gradInner: '渐变内色',
gradOuter: '渐变外色',
swap: '交换渐变端点',
swapRadial: '交换内色与外色',
angle: '渐变角度',
eye: '眼睛',
eyeReset: '恢复默认眼色',
group_lifecycle: '生命周期',
group_reactions: '反应',
group_agent: '代理',
@@ -173,8 +187,6 @@ const zh = {
},
manual: {
title: '手动调节',
help: '打开后拖身体转向,点眼睛出 L/H/S/R/E,或拖右上角的环旋转。',
on: '启用手动调节',
reset: '重置',
head: '头部旋转',
body: '身体',
@@ -186,6 +198,48 @@ const zh = {
spacing: '间距',
gizmo: '旋转控制器',
plane: '在画面上平移'
},
photo: {
open: '拍照模式',
exit: '退出拍照',
frameAria: '取景框。拖动移动角色,滚轮缩放,方向键微调。',
toolFrame: '取景',
toolPose: '姿态',
reset: '重置',
poseTitle: '姿态调节',
poseReset: '重置',
axisTilt: '俯仰',
axisTurn: '转向',
axisRoll: '翻滚',
posH: '水平',
posV: '垂直',
link: '联动',
eyeLeft: '左眼',
eyeRight: '右眼',
angle: '旋转',
position: '位置与间距',
composition: '构图',
positionX: '水平位置',
positionY: '垂直位置',
zoom: '缩放',
corner: '圆角',
recentre: '居中重置',
background: '背景',
fond_transparent: '透明',
fond_solid: '单色',
fond_linear: '线性渐变',
fond_radial: '径向渐变',
color: '颜色',
colorStart: '起点',
colorEnd: '终点',
colorInner: '内',
colorOuter: '外',
direction: '方向',
random: '随机配色',
expression: '表情',
exportTitle: '导出',
pixels: '{n} px',
take: '拍照'
}
}
+2
View File
@@ -11,6 +11,8 @@ export type ReplicaBot = {
setState: (name: string, opts?: { resetEyes?: boolean }) => void
setShape: (name: string) => void
setColor: (id: string, scheme?: string) => void
setInk: (flat: string | null) => void
setInkGrad: (grad: { from: string; to: string; angle: number; radial?: boolean } | null) => void
setFollowPointer: (on: boolean) => void
setEyeColor: (color: string | null) => void
setPaused: (on: boolean | 'hold-pose') => void
+69
View File
@@ -83,6 +83,75 @@ export function colorFill(id: string): string {
return colorHex(id)
}
export type InkSpec =
| { kind: 'preset'; id: string }
| { kind: 'flat'; hex: string }
| { kind: 'grad'; from: string; to: string; angle: number }
| { kind: 'radial'; from: string; to: string }
export type InkGrad = { from: string; to: string; angle: number; radial?: boolean }
const HEX6 = /^[0-9a-f]{6}$/i
function hex6(v: string | undefined): string | null {
const s = (v ?? '').trim().replace(/^#/, '')
return HEX6.test(s) ? `#${s.toLowerCase()}` : null
}
export function parseInk(v: string): InkSpec {
if (v.startsWith('g:')) {
const [a, b, d] = v.slice(2).split('-')
const from = hex6(a)
const to = hex6(b)
const angle = Math.round(Number(d))
if (from && to && Number.isFinite(angle)) {
return { kind: 'grad', from, to, angle: Math.min(360, Math.max(0, angle)) }
}
}
if (v.startsWith('r:')) {
const [a, b] = v.slice(2).split('-')
const from = hex6(a)
const to = hex6(b)
if (from && to) return { kind: 'radial', from, to }
}
if (v.startsWith('x:')) {
const hex = hex6(v.slice(2))
if (hex) return { kind: 'flat', hex }
}
return { kind: 'preset', id: v }
}
export function encodeFlat(hex: string): string {
return `x:${hex6(hex)?.slice(1) ?? '111111'}`
}
export function encodeGrad(from: string, to: string, angle: number): string {
const a = hex6(from) ?? '#111111'
const b = hex6(to) ?? '#111111'
return `g:${a.slice(1)}-${b.slice(1)}-${Math.round(angle)}`
}
export function encodeRadial(from: string, to: string): string {
const a = hex6(from) ?? '#111111'
const b = hex6(to) ?? '#111111'
return `r:${a.slice(1)}-${b.slice(1)}`
}
export function inkSwatch(v: string): string {
const spec = parseInk(v)
if (spec.kind === 'flat') return spec.hex
if (spec.kind === 'grad') return `linear-gradient(${spec.angle}deg, ${spec.from}, ${spec.to})`
if (spec.kind === 'radial') return `radial-gradient(circle at 50% 42%, ${spec.from}, ${spec.to})`
return colorFill(spec.id)
}
export function inkHex(v: string): string {
const spec = parseInk(v)
if (spec.kind === 'flat') return spec.hex
if (spec.kind === 'grad' || spec.kind === 'radial') return spec.from
return colorHex(spec.id)
}
export function estForme(id: string): id is ShapeId {
return (SHAPE_IDS as readonly string[]).includes(id)
}
+25 -2
View File
@@ -5,6 +5,7 @@ import {
type ReplicaParts,
type ReplicaPlayback
} from './boot'
import { parseInk } from './catalog'
export type ReplicaMount = {
apply: (input: ReplicaInput) => void
@@ -41,6 +42,20 @@ const TILE_MAX = 6
let tileActive = 0
const tileWait: Array<() => void> = []
function appliqueEncre(bot: ReplicaBot, v: string) {
const spec = parseInk(v)
if (spec.kind === 'preset') {
bot.setInk(null)
bot.setColor(spec.id)
} else if (spec.kind === 'flat') {
bot.setInk(spec.hex)
} else if (spec.kind === 'radial') {
bot.setInkGrad({ from: spec.from, to: spec.to, angle: 0, radial: true })
} else {
bot.setInkGrad({ from: spec.from, to: spec.to, angle: spec.angle })
}
}
function lockTile(): Promise<void> {
if (tileActive < TILE_MAX) {
tileActive++
@@ -82,10 +97,18 @@ export async function mountReplica(
if (!ok || !window.GrokCharacter) return null
const Re = window.GROK_GEO?.Re ?? 114.27
const driven = !!extra?.driven
const spec = parseInk(input.color)
const bot: ReplicaBot = new window.GrokCharacter(svg, {
state: input.state,
shape: input.shape,
color: input.color,
color: spec.kind === 'preset' ? spec.id : 'black',
inkFlat: spec.kind === 'flat' ? spec.hex : undefined,
inkGrad:
spec.kind === 'grad'
? { from: spec.from, to: spec.to, angle: spec.angle }
: spec.kind === 'radial'
? { from: spec.from, to: spec.to, angle: 0, radial: true }
: undefined,
sizePx: input.size,
mode: 'hold',
loginWrap: true,
@@ -127,7 +150,7 @@ export async function mountReplica(
apply(next) {
if (next.state !== last.state) bot.setState(next.state, { resetEyes: false })
if (next.shape !== last.shape) bot.setShape(next.shape)
if (next.color !== last.color) bot.setColor(next.color)
if (next.color !== last.color) appliqueEncre(bot, next.color)
if (next.follow !== last.follow) bot.setFollowPointer(next.follow)
if (next.paper !== last.paper) bot.setEyeColor(next.paper)
if (next.size !== last.size) bot.setSize(next.size)
+463
View File
@@ -409,6 +409,15 @@ html::-webkit-scrollbar {
grid-template-columns: 0 minmax(0, 1fr) 0;
}
/*
* En mode photo : la troisieme colonne reste reservee au panneau fixe a
* droite, l'avatar se centre donc dans ce qui reste, comme sur la vue
* personnaliser — et le rail reste visible a gauche.
*/
.scene--photo {
grid-template-columns: 0 minmax(0, 1fr) 21.25rem;
}
/*
* La boule dans les reglages : elle grandit jusqu'a sortir de l'ecran par la
* droite, et descend assez pour degager le haut de la page : sans cet air
@@ -657,6 +666,16 @@ html::-webkit-scrollbar {
transition: none;
animation: none;
}
.teinte-cadran,
.teinte-oeil-blanc {
transition: none;
}
.teinte-cadran:hover,
.teinte-oeil:hover .teinte-oeil-blanc {
transform: none;
}
}
body {
@@ -978,3 +997,447 @@ body {
}
/* ------------------------------------------------------------------ */
/* Mode photo : panneau de reglages */
/* ------------------------------------------------------------------ */
.photo-panneau input[type='number'] {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: white;
padding: 0.35rem 0.55rem;
font-size: 0.75rem;
color: var(--ink);
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.photo-panneau input[type='number']:focus {
outline: none;
border-color: var(--ink);
box-shadow: 0 0 0 3px rgb(23 32 58 / 0.08);
}
/* Les fleches natives debordent de champs si bas : on garde le clavier. */
.photo-panneau input[type='number']::-webkit-outer-spin-button,
.photo-panneau input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.photo-panneau input[type='number'] {
appearance: textfield;
}
.photo-etiquette {
font-size: 0.625rem;
line-height: 1.2;
color: var(--muted);
}
.photo-champ {
display: block;
min-width: 0;
}
.photo-bouton {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
border: none;
border-radius: 999px;
padding: 0.25rem 0.65rem;
font-size: 0.7rem;
color: var(--muted);
background: rgb(23 32 58 / 0.05);
transition:
color 120ms ease,
background 120ms ease;
}
.photo-bouton:hover {
color: var(--ink);
background: rgb(23 32 58 / 0.08);
}
.photo-bouton[aria-pressed='true'] {
color: var(--ink);
font-weight: 500;
}
.photo-bouton:focus-visible,
.photo-lien:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 1px;
}
/* Bascule « liaison » entre les deux yeux : icone de chaine, pleine si liee. */
.photo-lien {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
cursor: pointer;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: white;
transition:
border-color 120ms ease,
color 120ms ease,
background 120ms ease;
}
.photo-lien:hover {
border-color: var(--muted);
color: var(--ink);
}
.photo-lien[aria-pressed='true'] {
border-color: var(--ink);
background: var(--ink);
color: white;
}
/*
* Puces sur la barre de couleur. L'input natif, invisible, ouvre le
* selecteur systeme.
*/
.photo-puce {
position: absolute;
top: 50%;
left: 7px;
width: 1.375rem;
height: 1.375rem;
transform: translateY(-50%);
border-radius: 9999px;
cursor: pointer;
box-shadow:
0 0 0 2px white,
0 1px 3px rgb(23 32 58 / 0.3);
transition:
transform 120ms ease,
box-shadow 120ms ease;
}
.photo-puce:hover {
transform: translateY(-50%) scale(1.12);
}
/*
* Le contour d'encre ne se voit qu'au clavier : au clic, l'input garde le
* focus pendant que le selecteur systeme est ouvert et le cadre trainerait.
*/
.photo-puce:has(input:focus-visible) {
box-shadow:
0 0 0 2px white,
0 0 0 4px var(--ink);
}
.photo-puce--fin {
left: auto;
right: 7px;
}
.photo-puce--coeur {
left: 50%;
transform: translate(-50%, -50%);
}
.photo-puce--coeur:hover {
transform: translate(-50%, -50%) scale(1.12);
}
.photo-puce input[type='color'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
padding: 0;
opacity: 0;
cursor: pointer;
}
/* ------------------------------------------------------------------ */
/* Mode photo : cadre vivant, obturateur, flash */
/* ------------------------------------------------------------------ */
/*
* L'hote reste TOUJOURS dans l'arbre (display:contents quand inactif) :
* le slot contient l'AvatarStage du heros, jamais reparente.
*/
.photo-cadre-hote {
display: contents;
}
.photo-cadre {
position: relative;
z-index: 1;
display: block;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--photo-coins, 0%);
background: white;
box-shadow:
0 0 0 1px rgb(23 32 58 / 12%),
0 24px 60px rgb(23 32 58 / 16%);
transition:
border-radius 180ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1);
}
.photo-cadre.est-transparent {
background-color: white;
background-image:
linear-gradient(45deg, #e7e9ee 25%, transparent 25%),
linear-gradient(-45deg, #e7e9ee 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e7e9ee 75%),
linear-gradient(-45deg, transparent 75%, #e7e9ee 75%);
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;
background-size: 20px 20px;
}
.photo-sujet {
display: contents;
}
.photo-cadre .photo-sujet {
position: absolute;
inset: 0;
display: block;
transform-origin: center;
will-change: transform;
}
/* En outil cadrage, la couche d'interaction couvre les gizmos manuels. */
.photo-cadre.est-cadre .manual-layer {
opacity: 0;
pointer-events: none;
}
/*
* Zoom photo : le decor de reglage garde sa taille a l'ecran. Le sujet est
* mis a l'echelle par `.photo-sujet` ; les poignees sont contrées par
* 1 / echelle autour de leur propre centre, les traits hors des groupes
* contrés sont amincis d'autant.
*/
.photo-cadre .editor-control {
transform: scale(calc(1 / var(--photo-echelle, 1)));
transform-box: fill-box;
transform-origin: center;
}
.photo-cadre .editor-guide {
stroke-width: calc(1.15px / var(--photo-echelle, 1));
stroke-dasharray: calc(3px / var(--photo-echelle, 1)) calc(3px / var(--photo-echelle, 1));
}
.photo-cadre .selection-outline {
stroke-width: calc(2.4px / var(--photo-echelle, 1));
}
.photo-cadre .gizmo-cluster {
transform: scale(calc(1 / var(--photo-echelle, 1)));
transform-origin: top right;
}
.photo-cadre .axis-key {
transform: scale(calc(1 / var(--photo-echelle, 1)));
transform-origin: bottom right;
}
.photo-interaction {
position: absolute;
z-index: 12;
inset: 0;
cursor: grab;
outline: none;
touch-action: none;
}
.photo-interaction[data-dragging] {
cursor: grabbing;
}
.photo-interaction:focus-visible {
box-shadow: inset 0 0 0 2px var(--ink);
border-radius: inherit;
}
.photo-flash {
position: absolute;
z-index: 20;
inset: 0;
background: white;
pointer-events: none;
animation: photo-flash 380ms ease-out forwards;
}
@keyframes photo-flash {
from {
opacity: 0.95;
}
to {
opacity: 0;
}
}
/* Obturateur rond du declencheur et de l'entree. */
.photo-obturateur {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 99px;
background: currentColor;
box-shadow: 0 0 0 2px rgb(255 255 255 / 35%);
}
.photo-obturateur--petit {
width: 9px;
height: 9px;
box-shadow: 0 0 0 1.5px rgb(255 255 255 / 35%);
}
.photo-declencheur {
background: var(--ink);
color: var(--paper);
}
.photo-declencheur.occupe {
opacity: 0.6;
}
.photo-declencheur:not(.occupe):hover {
transform: translateY(-1px);
}
.photo-declencheur,
.photo-entree {
transition: transform 120ms ease;
}
/*
* Barre de couleur et cadran d'angle, en pilule : le nuancier, la boule
* et le mode photo parlent la meme langue ronde.
*/
.teinte-barre {
position: relative;
height: 2.25rem;
border-radius: 999px;
box-shadow: inset 0 0 0 1px rgb(23 32 58 / 0.1);
}
.teinte-barre:has(input:focus-visible) {
box-shadow:
inset 0 0 0 1px rgb(23 32 58 / 0.1),
0 0 0 2px var(--ink);
}
.teinte-cadran {
position: relative;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
border-radius: 999px;
cursor: grab;
user-select: none;
touch-action: none;
box-shadow:
inset 0 0 0 1px rgb(23 32 58 / 0.12),
0 1px 2px rgb(23 32 58 / 0.08);
transition: transform 120ms ease;
}
.teinte-cadran:hover {
transform: scale(1.06);
}
.teinte-cadran:active {
cursor: grabbing;
transform: scale(0.98);
}
.teinte-cadran:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.teinte-cadran-aiguille {
position: absolute;
top: 3px;
left: 50%;
width: 5px;
height: 5px;
margin-left: -2.5px;
border-radius: 999px;
background: white;
box-shadow: 0 0 0 1px rgb(23 32 58 / 0.28);
}
.teinte-oeil {
position: relative;
display: grid;
width: 2.75rem;
height: 2.75rem;
place-items: center;
cursor: pointer;
}
.teinte-oeil-blanc {
display: block;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
box-shadow: inset 0 0 0 1px rgb(23 32 58 / 0.1);
transition:
box-shadow 120ms ease,
transform 120ms ease;
}
.teinte-oeil:hover .teinte-oeil-blanc {
transform: scale(1.06);
}
.teinte-oeil:has(input:focus-visible) .teinte-oeil-blanc,
.teinte-oeil.est-teint .teinte-oeil-blanc {
box-shadow:
inset 0 0 0 1px rgb(23 32 58 / 0.1),
0 0 0 2px var(--ink);
}
.teinte-oeil-pupille {
position: absolute;
width: 0.7rem;
height: 0.7rem;
border-radius: 999px;
background: var(--ink);
box-shadow: 0 0 0 2px rgb(255 255 255 / 0.7);
pointer-events: none;
}
.teinte-oeil input[type='color'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
opacity: 0;
cursor: pointer;
}
+19
View File
@@ -0,0 +1,19 @@
/** Angle CSS : 0 vers le haut, sens horaire, ramene sur [0, 360). */
export function tourAngle(a: number) {
return ((Math.round(a) % 360) + 360) % 360
}
/** Angle depuis le centre d'un cadran, meme convention que `linear-gradient`. */
export function angleDepuisPointeur(el: HTMLElement, e: PointerEvent) {
const r = el.getBoundingClientRect()
const x = e.clientX - (r.left + r.width / 2)
const y = e.clientY - (r.top + r.height / 2)
return (Math.atan2(x, -y) * 180) / Math.PI
}
export const PAS_CADRAN: Record<string, number> = {
ArrowLeft: -15,
ArrowUp: -15,
ArrowRight: 15,
ArrowDown: 15
}
+2 -1
View File
@@ -123,6 +123,7 @@ export type Avancement = (fait: number, total: number) => void
export interface ReglagesBot {
shape: string
color: string
oeil?: string
expression: string
state?: string
}
@@ -133,7 +134,7 @@ function inputOf(reglages: ReglagesBot, taille: number, paper?: string): Replica
shape: reglages.shape,
color: reglages.color,
follow: false,
paper: paper ?? '#f9f9f9',
paper: paper ?? reglages.oeil ?? '#f9f9f9',
size: taille
}
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Mode photo : composition du cadrage, fonds et assemblage du SVG final.
* Pur — aucun DOM. Le cadre fait 300 unites (viewBox -150 -150 300 300) :
* en CSS, translate(x/3 %) sur un cadre carre egale translate(x) en SVG,
* et scale() d'origine centre egale au couple translate/scale autour de 0.
*/
export type CompositionPhoto = Readonly<{
x: number
y: number
scale: number
cornerRadius: number
}>
export const COMPOSITION_DEFAUT: CompositionPhoto = { x: 0, y: 0, scale: 1, cornerRadius: 0 }
const borne = (valeur: number, min: number, max: number) =>
Math.min(max, Math.max(min, Number.isFinite(valeur) ? valeur : min))
export const normaliseComposition = (c: CompositionPhoto): CompositionPhoto => ({
x: borne(c.x, -180, 180),
y: borne(c.y, -180, 180),
scale: borne(c.scale, 0.4, 3),
cornerRadius: borne(c.cornerRadius, 0, 50)
})
/** Pourcent (0-50) vers unites viewBox du cadre 300. */
export const rayonCoins = (cornerRadius: number) => borne(cornerRadius, 0, 50) * 3
export type FondPhoto = 'transparent' | 'solid' | 'linear' | 'radial'
/** Direction du linear en degres CSS : 0 = vers le haut, 135 = vers le bas-droite. */
export const ANGLE_DEFAUT = 135
export type OptionsPhoto = {
fond: FondPhoto
couleurDe: string
couleurA: string
angle: number
taille: number
composition: CompositionPhoto
}
/** Fond CSS du cadre vivant — meme geometrie que `degrades`/`rectFond`. */
export function fondCss(
fond: FondPhoto,
couleurDe: string,
couleurA: string,
angle: number = ANGLE_DEFAUT
) {
if (fond === 'solid') return couleurDe
if (fond === 'linear') return `linear-gradient(${angle}deg, ${couleurDe}, ${couleurA})`
if (fond === 'radial') return `radial-gradient(circle at 50% 42%, ${couleurDe}, ${couleurA})`
return undefined
}
const degrades = (o: OptionsPhoto) => {
if (o.fond === 'linear') {
/*
* Le repere du cadre est carre : pivoter le vecteur diagonale (0,0)→(1,1)
* autour du centre egale exactement l'angle CSS, la diagonale valant 135°.
*/
const rotation = Math.round(o.angle) - 135
const transform = rotation % 360 === 0 ? '' : ` gradientTransform="rotate(${rotation} 0.5 0.5)"`
return `<linearGradient id="photo-linear" x1="0" y1="0" x2="1" y2="1"${transform}><stop offset="0" stop-color="${o.couleurDe}"/><stop offset="1" stop-color="${o.couleurA}"/></linearGradient>`
}
if (o.fond === 'radial') {
return `<radialGradient id="photo-radial" cx="50%" cy="42%" r="70%"><stop offset="0" stop-color="${o.couleurDe}"/><stop offset="1" stop-color="${o.couleurA}"/></radialGradient>`
}
return ''
}
const rectFond = (o: OptionsPhoto) => {
if (o.fond === 'transparent') return ''
const fill = o.fond === 'solid' ? o.couleurDe : `url(#photo-${o.fond})`
return `<rect x="-150" y="-150" width="300" height="300" fill="${fill}"/>`
}
/**
* Enveloppe l'avatar autonome (sortie de `svgAutonome` en 300 x 300) dans le
* cadre photo : degrade, fond, coins arrondis, translation et echelle.
*/
export function svgPhoto(markup: string, options: OptionsPhoto) {
const o = { ...options, composition: normaliseComposition(options.composition) }
const c = o.composition
const interne = markup.replace('<svg ', '<svg x="-150" y="-150" ')
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-150 -150 300 300" width="${o.taille}" height="${o.taille}" role="img">
<defs>${degrades(o)}<clipPath id="photo-cadre"><rect x="-150" y="-150" width="300" height="300" rx="${rayonCoins(c.cornerRadius)}"/></clipPath></defs>
<g clip-path="url(#photo-cadre)">${rectFond(o)}<g transform="translate(${c.x} ${c.y}) scale(${c.scale})">${interne}</g></g>
</svg>`
}
/* ------------------------------------------------------------------ */
/* Palettes aleatoires OKLch, contraste >= 3:1 avec la couleur du corps */
/* ------------------------------------------------------------------ */
export type StylePalette = 'solid' | 'linear' | 'radial'
export type PalettePhoto = {
couleurDe: string
couleurA: string
}
type CouleurOklch = {
lightness: number
chroma: number
hue: number
}
const hexVersRgb = (hex: string) => {
const normalise = hex.replace('#', '')
if (!/^[0-9a-f]{6}$/i.test(normalise)) return null
return {
red: Number.parseInt(normalise.slice(0, 2), 16),
green: Number.parseInt(normalise.slice(2, 4), 16),
blue: Number.parseInt(normalise.slice(4, 6), 16)
}
}
const luminanceRelative = (hex: string) => {
const rgb = hexVersRgb(hex)
if (!rgb) return 0
const canal = (valeur: number) => {
const normalise = valeur / 255
return normalise <= 0.04045
? normalise / 12.92
: ((normalise + 0.055) / 1.055) ** 2.4
}
return 0.2126 * canal(rgb.red) + 0.7152 * canal(rgb.green) + 0.0722 * canal(rgb.blue)
}
export const contrastePalette = (premiere: string, seconde: string) => {
const l1 = luminanceRelative(premiere)
const l2 = luminanceRelative(seconde)
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
}
const bornePalette = (valeur: number, min = 0, max = 1) =>
Math.min(Math.max(valeur, min), max)
const lineaireVersSrgb = (valeur: number) => {
const normalise = bornePalette(valeur)
return normalise <= 0.0031308
? normalise * 12.92
: 1.055 * normalise ** (1 / 2.4) - 0.055
}
const canalVersHex = (valeur: number) =>
Math.round(bornePalette(valeur) * 255)
.toString(16)
.padStart(2, '0')
.toUpperCase()
const oklchVersHex = ({ lightness, chroma, hue }: CouleurOklch) => {
const radians = (hue * Math.PI) / 180
const a = chroma * Math.cos(radians)
const b = chroma * Math.sin(radians)
const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b
const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b
const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b
const l = lPrime ** 3
const m = mPrime ** 3
const s = sPrime ** 3
const red = lineaireVersSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)
const green = lineaireVersSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)
const blue = lineaireVersSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
return `#${canalVersHex(red)}${canalVersHex(green)}${canalVersHex(blue)}`
}
const memePalette = (a: PalettePhoto, b: PalettePhoto) =>
a.couleurDe.toLowerCase() === b.couleurDe.toLowerCase() &&
a.couleurA.toLowerCase() === b.couleurA.toLowerCase()
const contrasteMinimal = (palette: PalettePhoto, avantPlan: string) =>
Math.min(
contrastePalette(palette.couleurDe, avantPlan),
contrastePalette(palette.couleurA, avantPlan)
)
const teinteEssai = (alea: () => number, essai: number) =>
(alea() * 360 + essai * 137.508) % 360
const generePalette = (
style: StylePalette,
fondSombre: boolean,
alea: () => number,
essai: number
): PalettePhoto => {
const teinte = teinteEssai(alea, essai)
const chroma = 0.065 + alea() * 0.055
if (style === 'solid') {
const lightness = fondSombre ? 0.3 + alea() * 0.14 : 0.78 + alea() * 0.13
const couleur = oklchVersHex({ lightness, chroma, hue: teinte })
return { couleurDe: couleur, couleurA: couleur }
}
const decalTeinte = 18 + alea() * 38
const secondeTeinte = (teinte + (alea() > 0.5 ? decalTeinte : -decalTeinte) + 360) % 360
if (style === 'radial') {
const lCentre = fondSombre ? 0.36 : 0.94
const lBord = fondSombre ? 0.12 : 0.7
return {
couleurDe: oklchVersHex({ lightness: lCentre, chroma: chroma * 0.82, hue: teinte }),
couleurA: oklchVersHex({
lightness: lBord,
chroma: chroma * (fondSombre ? 0.42 : 0.85),
hue: secondeTeinte
})
}
}
const lDebut = fondSombre ? 0.38 : 0.92
const lFin = fondSombre ? 0.2 : 0.74
return {
couleurDe: oklchVersHex({ lightness: lDebut, chroma, hue: teinte }),
couleurA: oklchVersHex({ lightness: lFin, chroma: chroma * 0.9, hue: secondeTeinte })
}
}
export const paletteAleatoire = (
style: StylePalette,
avantPlan: string,
actuelle: PalettePhoto,
alea: () => number = Math.random
): PalettePhoto => {
const contrasteSombre = contrastePalette(avantPlan, '#15191F')
const contrasteClair = contrastePalette(avantPlan, '#F2E8D4')
const fondSombre =
contrasteSombre >= 3 && contrasteClair >= 3
? alea() >= 0.5
: contrasteSombre >= contrasteClair
let meilleure: PalettePhoto | null = null
let meilleurContraste = 0
for (let essai = 0; essai < 24; essai += 1) {
const palette = generePalette(style, fondSombre, alea, essai)
if (memePalette(palette, actuelle)) continue
const contraste = contrasteMinimal(palette, avantPlan)
if (contraste >= 3) return palette
if (contraste > meilleurContraste) {
meilleure = palette
meilleurContraste = contraste
}
}
return meilleure ?? generePalette(style, fondSombre, alea, 25)
}
+22 -18
View File
@@ -83,7 +83,9 @@ const nearestEquivalentAngle = (angle: number, current: number) => {
return clamp(result, -365, 365)
}
export const POSE_HOME: HeadPose = { turn: 33, tilt: -19, roll: 38 }
// Engine eyes.js EYE_HOME: the frame where eyes rest centered. Wires, gizmo
// and eye projection share it so the grid's central meridian splits the eyes.
export const EYE_HOME: HeadPose = { turn: 17, tilt: -14, roll: 29 }
export const conjugateFlipY = ([w, x, y, z]: Quaternion): Quaternion => [w, -x, y, -z]
@@ -99,18 +101,18 @@ export const poseFromOrientation = (pose: HeadPose, orientation: Quaternion): He
}
}
export const visualOrientation = (pose: HeadPose, home: HeadPose = POSE_HOME): Quaternion => {
export const visualOrientation = (pose: HeadPose, home: HeadPose = EYE_HOME): Quaternion => {
const [w, x, y, z] = orientationOf(home)
return multiplyQuaternions(orientationOf(pose), [w, -x, -y, -z])
}
export const screenOrientation = (pose: HeadPose, home: HeadPose = POSE_HOME): Quaternion =>
export const screenOrientation = (pose: HeadPose, home: HeadPose = EYE_HOME): Quaternion =>
conjugateFlipY(visualOrientation(pose, home))
export const poseFromScreen = (
pose: HeadPose,
screen: Quaternion,
home: HeadPose = POSE_HOME
home: HeadPose = EYE_HOME
): HeadPose =>
poseFromOrientation(pose, multiplyQuaternions(conjugateFlipY(screen), orientationOf(home)))
@@ -229,7 +231,7 @@ export const projectLocalPoint = (
pose: HeadPose,
local: Point3,
origin: readonly [number, number] = [0, 0],
home: HeadPose = POSE_HOME
home: HeadPose = EYE_HOME
): Point3 => {
const rotated = rotateWithQuaternion(screenOrientation(pose, home), local)
const scale = FOCAL / Math.max(FOCAL - rotated[2] * PERSPECTIVE, 0.0001)
@@ -278,7 +280,7 @@ export const projectEyePoint = (
localX: number,
localY: number,
angleDeg: number,
home: HeadPose = POSE_HOME
home: HeadPose = EYE_HOME
): Point3 => {
const angle = radians(angleDeg)
const cosine = Math.cos(angle)
@@ -299,22 +301,24 @@ export const projectFacePoint = (
y: number,
radius: number,
origin: readonly [number, number] = [radius, radius],
home: HeadPose = POSE_HOME
home: HeadPose = EYE_HOME
): Point3 => {
const longitude = x / radius
const latitude = y / radius
const cosine = Math.cos(latitude)
const local: Point3 = [
cosine * Math.sin(longitude),
Math.sin(latitude),
cosine * Math.cos(longitude)
]
const diskX = x / radius
const diskY = y / radius
// Face coords are authored against RADIUS (original lab units) but ride the
// visible head: rim = RADIUS scaled to the head edge in overlay units, so
// the eye sphere silhouette lands on the head outline (engine parity).
const rim = radius * (95 / 120)
// Past the silhouette points stay raw on the z=0 plane and get sliced by
// the head clip instead of folding back inward.
const diskZ = Math.sqrt(Math.max(0, 1 - diskX * diskX - diskY * diskY))
const local: Point3 = [diskX, diskY, diskZ]
const rotated = rotateWithQuaternion(screenOrientation(pose, home), local)
const depth = rotated[2] * radius
const depth = rotated[2] * rim
const scale = FOCAL / Math.max(FOCAL - depth * PERSPECTIVE, 0.0001)
return [
origin[0] + rotated[0] * radius * scale,
origin[1] + rotated[1] * radius * scale,
origin[0] + rotated[0] * rim * scale,
origin[1] + rotated[1] * rim * scale,
rotated[2]
]
}
+4 -3
View File
@@ -15,7 +15,7 @@ export type EyeTune = {
spacing: number
}
export type EyeLinks = { width: boolean; height: boolean; size: boolean }
export type EyeLinks = { width: boolean; height: boolean; size: boolean; rotation: boolean }
export type BodyOffset = { tx: number; ty: number; spin: number }
@@ -42,7 +42,7 @@ export const DEFAULT_EYES: EyeTune = {
spacing: 35
}
export const DEFAULT_LINKS: EyeLinks = { width: true, height: true, size: true }
export const DEFAULT_LINKS: EyeLinks = { width: true, height: true, size: true, rotation: true }
export const defaultManual = (): ManualState => ({
on: false,
@@ -129,7 +129,8 @@ export const parseManual = (raw: string | null): ManualState => {
links: {
width: links.width !== false,
height: links.height !== false,
size: links.size !== false
size: links.size !== false,
rotation: links.rotation !== false
},
selected
}
+1 -1
View File
@@ -1,5 +1,5 @@
const PREFIXE = 'study:'
const NOMS = ['cycles', 'cycle', 'forme', 'couleur', 'expression', 'langue', 'manuel'] as const
const NOMS = ['cycles', 'cycle', 'forme', 'couleur', 'oeil', 'expression', 'langue', 'manuel'] as const
export type NomStocke = (typeof NOMS)[number]