first 3D model
All checks were successful
Deploy App / deploy (push) Successful in 1m18s

This commit is contained in:
valere
2025-09-17 23:39:43 +02:00
parent 116d15d1ce
commit 0c1cf30996
28 changed files with 1384 additions and 63 deletions

263
app/components/boxItem.vue Normal file
View File

@@ -0,0 +1,263 @@
<template>
<div class="scene" id="scene">
<div class="box" id="box">
<div class="face front">
<img class="cover" src="https://evilspins.com/ES01A/object.png" alt="">
</div>
<div class="face back"></div>
<div class="face right"></div>
<div class="face left"></div>
<div class="face top"></div>
<div class="face bottom"></div>
</div>
</div>
</template>
<script setup>
/*
Rotation 3D au touch / clic maintenu
- Utilise Pointer Events (fonctionne pour souris, stylet, touch)
- rotateX et rotateY mis à jour pendant le drag
- inertie légère à la release (configurable)
*/
(function () {
const box = document.getElementById('box');
const scene = document.getElementById('scene');
// état
let angleX = -20; // angle initial (pour montrer la 3D)
let angleY = 20;
let dragging = false;
let lastPointer = { x: 0, y: 0, time: 0 };
let velocity = { x: 0, y: 0 };
let raf = null;
// paramètres
const sensitivity = 0.3; // combien de degrés par px
const friction = 0.95; // inertie (0..1) ; 1 = pas de friction
const minVelocity = 0.02; // seuil pour arrêter l'inertie
const enableInertia = true; // tu peux mettre false pour pas d'inertie
// applique la transformation (raf-friendly)
function applyTransform() {
box.style.transform = `rotateX(${angleX}deg) rotateY(${angleY}deg)`;
}
applyTransform();
// boucle d'inertie
function tickInertia() {
if (!enableInertia) return;
// appliquer friction
velocity.x *= friction;
velocity.y *= friction;
angleX += velocity.y;
angleY += velocity.x;
// clamp angleX pour éviter flip complet (optionnel)
angleX = Math.max(-80, Math.min(80, angleX));
applyTransform();
if (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity) {
raf = requestAnimationFrame(tickInertia);
} else {
raf = null;
}
}
// pointerdown
scene.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
dragging = true;
scene.setPointerCapture(ev.pointerId);
lastPointer.x = ev.clientX;
lastPointer.y = ev.clientY;
lastPointer.time = performance.now();
velocity.x = 0;
velocity.y = 0;
if (raf) { cancelAnimationFrame(raf); raf = null; } // stop inertie en commençant un nouveau drag
});
// pointermove
scene.addEventListener('pointermove', (ev) => {
if (!dragging) return;
ev.preventDefault();
const now = performance.now();
const dx = ev.clientX - lastPointer.x;
const dy = ev.clientY - lastPointer.y;
// mise à jour angles (inverser si tu veux sens différent)
angleY += dx * sensitivity;
angleX -= dy * sensitivity;
// clamp angleX
angleX = Math.max(-80, Math.min(80, angleX));
// calculer vitesse (px per frame approximative)
const dt = Math.max(1, now - lastPointer.time); // ms
velocity.x = (dx / dt) * 16 * sensitivity; // normalisé approximativement à 60fps
velocity.y = (-dy / dt) * 16 * sensitivity;
lastPointer.x = ev.clientX;
lastPointer.y = ev.clientY;
lastPointer.time = now;
applyTransform();
});
// pointerup / cancel
const endDrag = (ev) => {
if (!dragging) return;
dragging = false;
try { scene.releasePointerCapture(ev.pointerId); } catch (e) { }
// si inertie activée, lancer la boucle
if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
if (!raf) raf = requestAnimationFrame(tickInertia);
}
};
scene.addEventListener('pointerup', endDrag);
scene.addEventListener('pointercancel', endDrag);
scene.addEventListener('pointerleave', endDrag); // utile sur desktop si tu sors du container
// accessibility : permettre rotation via clavier (optionnel)
scene.tabIndex = 0;
scene.addEventListener('keydown', (e) => {
const step = 8;
if (e.key === 'ArrowLeft') { angleY -= step; applyTransform(); }
if (e.key === 'ArrowRight') { angleY += step; applyTransform(); }
if (e.key === 'ArrowUp') { angleX -= step; applyTransform(); }
if (e.key === 'ArrowDown') { angleX += step; applyTransform(); }
});
})();
</script>
<style>
/* H W D
k7 5 : 8 : 1
CD 12 : 14 : 1
VHS 4.5 : 8 : 1
DVD 14 : 10 : 1
*/
:root {
--size: 100px;
--height: 150px;
--width: 150px;
--depth: 10px;
--bg: #0f172a;
}
html,
body {
height: 100%;
margin: 0;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial;
-webkit-font-smoothing: antialiased;
}
/* scène avec perspective */
.scene {
width: calc(var(--height) * 1.2);
height: calc(var(--width) * 1.2);
perspective: 1000px;
touch-action: none;
/* essentiel pour empêcher le scroll pendant le drag */
}
/* l'objet 3D (box simple) */
.box {
width: var(--width);
height: var(--height);
position: relative;
transform-style: preserve-3d;
transition: transform 120ms linear;
/* légère smoothing quand on lâche */
margin: auto;
user-select: none;
cursor: grab;
}
.box:active {
cursor: grabbing;
}
/* faces du box */
.face {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
font-weight: 600;
backface-visibility: hidden;
border: 2px solid rgba(255, 255, 255, 0.06);
box-sizing: border-box;
transform-origin: top right;
}
.front,
.back {
width: 100%;
height: 100%;
}
.face.top,
.face.bottom {
width: var(--width);
height: var(--depth);
}
.face.left,
.face.right {
width: var(--depth);
height: var(--height);
}
.face.front {
background: linear-gradient(180deg, #2563eb, #7dd3fc);
transform: translateX(0px) translateY(0px) translateZ(0px);
}
.face.back {
background: linear-gradient(180deg, #7c3aed, #a78bfa);
transform: rotateY(180deg) translateX(var(--width)) translateY(0px) translateZ(var(--depth));
}
.face.right {
background: linear-gradient(180deg, #10b981, #6ee7b7);
transform: rotateY(90deg) translateX(0px) translateY(0px) translateZ(var(--width));
transform-origin: top left;
}
.face.left {
background: linear-gradient(180deg, #ef4444, #fca5a5);
transform: rotateY(-90deg) translateX(0) translateY(0px) translateZ(var(--depth));
}
.face.top {
background: linear-gradient(180deg, #f59e0b, #fcd34d);
transform: rotateX(90deg) translateX(0px) translateY(calc(var(--depth) * -1)) translateZ(0px);
}
.face.bottom {
background: linear-gradient(180deg, #06b6d4, #67e8f9);
transform: rotateX(-90deg) translateX(0) translateY(0px) translateZ(calc(var(--height)));
}
.cover {
height: 100%;
width: 100%;
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,338 @@
<template>
<!-- scène 3D -->
<div ref="scene" class="scene z-10">
<div ref="box" class="box">
<div class="face front relative" ref="frontFace">
<img class="cover absolute" :src="`/${compilation.id}/cover.jpg`" alt="">
</div>
<div class="face back" ref="backFace" />
<div class="face right" ref="rightFace" />
<div class="face left" ref="leftFace" />
<div class="face top" ref="topFace">
<img class="logo h-full p-1" src="/logo.svg" alt="">
<img class="absolute left-0 w-16 pr-4" :src="`/${compilation.id}/title.svg`" alt="">
</div>
<div class="face bottom" ref="bottomFace" />
</div>
</div>
<div class="devtool absolute right-4 text-white bg-black rounded-2xl px-4 py-2">
<button @click="poser">poser</button>
<button @click="face">face</button>
<button @click="dos">dos</button>
<div>
<label class="block">
X: {{ angleX }}
<input v-model.number="angleX" type="range" step="1" min="-180" max="180" @input="applyTransform">
</label>
<label class="block">
Y: {{ angleY }}
<input v-model.number="angleY" type="range" step="1" min="-180" max="180" @input="applyTransform">
</label>
<label class="block">
Z: {{ angleZ }}
<input v-model.number="angleZ" type="range" step="1" min="-180" max="180" @input="applyTransform">
</label>
</div>
</div>
</template>
<script setup lang="ts">
import compilations from '~~/server/api/compilations';
import type { Compilation } from '~~/types/types';
const props = defineProps<{ compilation: Compilation }>()
// States
const position = ref('')
const angleX = ref(76)
const angleY = ref(0)
const angleZ = ref(150)
const frontFace = ref()
const backFace = ref()
const rightFace = ref()
const leftFace = ref()
const topFace = ref()
const bottomFace = ref()
function poser() {
rotateBox(76, 0, 150)
}
function face() {
rotateBox(-20, 20, 0)
}
function dos() {
rotateBox(-20, 200, 0)
}
const box = ref()
const scene = ref()
/*
ÉTATS POUR LE DRAG + INERTIE
*/
let dragging = false
let lastPointer = { x: 0, y: 0, time: 0 } // position précédente du pointeur
let velocity = { x: 0, y: 0 } // vitesse calculée pour inertie
let raf = null // id du requestAnimationFrame
/*
PARAMÈTRES DE RÉGLAGE
*/
const sensitivity = 0.3 // combien de degrés par pixel de mouvement
const friction = 0.95 // inertie : 1 = sans perte, plus bas = ralentit vite
const minVelocity = 0.02 // seuil sous lequel on arrête linertie
const enableInertia = true // true = inertie activée, false = rotation immédiate sans suite
/*
Applique la transformation CSS à la box
*/
function applyTransform() {
angleX.value = Math.round(angleX.value)
angleY.value = Math.round(angleY.value)
angleZ.value = Math.round(angleZ.value)
box.value.style.transform = `rotateX(${angleX.value}deg) rotateY(${angleY.value}deg) rotateZ(${angleZ.value}deg)`
}
/*
Fonction utilitaire : place la box directement à une rotation donnée
(utilisée par le bouton reset ou autre logique externe)
*/
function rotateBox(x, y, z) {
angleX.value = x
angleY.value = y
angleZ.value = z
applyTransform()
box.value.style.setProperty('transition', 'transform 800ms ease-in-out')
setTimeout(() => {
box.value.style.setProperty('transition', 'transform 120ms linear')
console.log('dopne timeout')
}, 120)
}
/*
Boucle dinertie après un drag
- reprend la vitesse calculée à la release
- diminue petit à petit avec friction
- stoppe quand vitesse < minVelocity
*/
function tickInertia() {
if (!enableInertia) return
// appliquer la friction
velocity.x *= friction
velocity.y *= friction
// appliquer au box
angleX.value += velocity.y
angleY.value += velocity.x
// clamp angleX pour éviter de retourner la box trop loin
angleX.value = Math.max(-80, Math.min(80, angleX.value))
applyTransform()
// continuer tant quil reste du mouvement
if (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity) {
raf = requestAnimationFrame(tickInertia)
} else {
raf = null
}
}
/*
Mise en place des listeners une fois le composant monté
*/
onMounted(() => {
// setup CSS vars pour la scène
updateCssVar('--height', '200px', scene.value)
updateCssVar('--width', '300px', scene.value)
updateCssVar('--depth', '60px', scene.value)
frontFace.value.style.setProperty('background', `${props.compilation.colorTo}`)
backFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.colorFrom}, ${props.compilation.colorTo})`)
rightFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.colorFrom}, ${props.compilation.colorTo})`)
leftFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.colorFrom}, ${props.compilation.colorTo})`)
topFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.colorTo}, ${props.compilation.colorTo})`)
bottomFace.value.style.setProperty('background', `${props.compilation.colorFrom}`)
applyTransform()
// pointerdown = début du drag
const down = (ev) => {
ev.preventDefault()
dragging = true
scene.value.setPointerCapture(ev.pointerId)
lastPointer = { x: ev.clientX, y: ev.clientY, time: performance.now() }
velocity = { x: 0, y: 0 }
if (raf) { cancelAnimationFrame(raf); raf = null }
}
// pointermove = on bouge la souris ou le doigt
const move = (ev) => {
if (!dragging) return
ev.preventDefault()
const now = performance.now()
const dx = ev.clientX - lastPointer.x
const dy = ev.clientY - lastPointer.y
// mise à jour des angles
angleY.value += dx * sensitivity
angleX.value -= dy * sensitivity
angleX.value = Math.max(-80, Math.min(80, angleX.value))
// calcul vitesse pour inertie
const dt = Math.max(1, now - lastPointer.time)
velocity.x = (dx / dt) * 16 * sensitivity
velocity.y = (-dy / dt) * 16 * sensitivity
lastPointer = { x: ev.clientX, y: ev.clientY, time: now }
applyTransform()
}
// pointerup = fin du drag
const end = (ev) => {
if (!dragging) return
dragging = false
try { scene.value.releasePointerCapture(ev.pointerId) } catch { }
if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
if (!raf) raf = requestAnimationFrame(tickInertia)
}
}
// attach des events
scene.value.addEventListener('pointerdown', down)
scene.value.addEventListener('pointermove', move)
scene.value.addEventListener('pointerup', end)
scene.value.addEventListener('pointercancel', end)
scene.value.addEventListener('pointerleave', end)
// cleanup au démontage
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
})
})
</script>
<style>
html,
body {
height: 100%;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial;
-webkit-font-smoothing: antialiased;
}
/* scène avec perspective */
.scene {
height: var(--height);
width: var(--width);
perspective: 1000px;
touch-action: none;
/* essentiel pour empêcher le scroll pendant le drag */
/* height: 20px; */
transition: all .5s;
}
/* l'objet 3D (box simple) */
.box {
width: var(--width);
height: var(--height);
position: relative;
transform-style: preserve-3d;
transition: transform 120ms linear;
/* légère smoothing quand on lâche */
margin: auto;
user-select: none;
cursor: grab;
}
.box:active {
cursor: grabbing;
}
/* faces du box */
.face {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
font-weight: 600;
backface-visibility: hidden;
/* border: 2px solid rgba(255, 255, 255, 0.06); */
box-sizing: border-box;
transform-origin: top right;
}
.front,
.back {
width: 100%;
height: 100%;
}
.face.top,
.face.bottom {
width: var(--width);
height: var(--depth);
}
.face.left,
.face.right {
width: var(--depth);
height: var(--height);
}
.face.front {
transform: translateX(0px) translateY(0px) translateZ(0px);
}
.face.back {
transform: rotateY(180deg) translateX(var(--width)) translateY(0px) translateZ(var(--depth));
}
.face.right {
transform: rotateY(90deg) translateX(0px) translateY(0px) translateZ(var(--width));
transform-origin: top left;
}
.face.left {
transform: rotateY(-90deg) translateX(0) translateY(0px) translateZ(var(--depth));
}
.face.top {
transform: rotateX(90deg) translateX(0px) translateY(calc(var(--depth) * -1)) translateZ(0px);
}
.face.top>* {
@apply rotate-180;
}
.face.bottom {
transform: rotateX(-90deg) translateX(0) translateY(0px) translateZ(calc(var(--height)));
}
.cover {
height: 100%;
width: 100%;
object-fit: cover;
}
.logo {
filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8));
}
</style>