feat:加上拍照模式与自定义颜色渐变
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 })"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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: '拍照'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user