feat: implement split rendering for smooth layer rotation, bump version to 0.0.3
All checks were successful
Deploy to Production / deploy (push) Successful in 12s
All checks were successful
Deploy to Production / deploy (push) Successful in 12s
This commit is contained in:
6
package-lock.json
generated
6
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@gkucmierz/utils": "^1.28.3",
|
||||
"lucide-vue-next": "^0.564.0",
|
||||
"matrix-js": "^1.8.0",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -985,6 +986,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-js": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js/-/matrix-js-1.8.0.tgz",
|
||||
"integrity": "sha512-2PHn6veiSf7aS/VBhdgrUVYCjVBkaAwFtIuXUnrHduKbSNpFHYzkdPYPgKI95idqFMKKEieYoMglimo2YGIapQ=="
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rubic-cube",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@gkucmierz/utils": "^1.28.3",
|
||||
"lucide-vue-next": "^0.564.0",
|
||||
"matrix-js": "^1.8.0",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,215 +1,29 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useCube } from '../composables/useCube'
|
||||
import { computed } from 'vue'
|
||||
import { useRenderer } from '../composables/useRenderer'
|
||||
import CubeCSS from './renderers/CubeCSS.vue'
|
||||
import CubeSVG from './renderers/CubeSVG.vue'
|
||||
import CubeCanvas from './renderers/CubeCanvas.vue'
|
||||
|
||||
const { cubeState, initCube, rotateLayer, COLOR_MAP } = useCube()
|
||||
const { activeRenderer, RENDERERS } = useRenderer()
|
||||
|
||||
const getFaceColors = (faceName) => {
|
||||
if (!cubeState.value || !cubeState.value[faceName]) {
|
||||
// Return placeholder colors if state is not ready
|
||||
return Array(9).fill('#333');
|
||||
const currentRendererComponent = computed(() => {
|
||||
switch (activeRenderer.value) {
|
||||
case RENDERERS.CSS:
|
||||
return CubeCSS
|
||||
case RENDERERS.SVG:
|
||||
return CubeSVG
|
||||
case RENDERERS.CANVAS:
|
||||
return CubeCanvas
|
||||
default:
|
||||
return CubeCSS
|
||||
}
|
||||
// cubeState.value[faceName] is a 3x3 matrix.
|
||||
// We need to flatten it.
|
||||
try {
|
||||
return cubeState.value[faceName].flat().map(c => COLOR_MAP[c] || 'gray');
|
||||
} catch (e) {
|
||||
console.error('Error mapping face colors', e);
|
||||
return Array(9).fill('red'); // Error state
|
||||
}
|
||||
};
|
||||
|
||||
// ...ref(25)
|
||||
const ry = ref(25)
|
||||
const rz = ref(0)
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragMode = ref('view') // 'view' or 'layer'
|
||||
const startMouseX = ref(0)
|
||||
const startMouseY = ref(0)
|
||||
const lastMouseX = ref(0)
|
||||
const lastMouseY = ref(0)
|
||||
const selectedFace = ref(null)
|
||||
const selectedStickerIndex = ref(null)
|
||||
|
||||
const onMouseDown = (event) => {
|
||||
isDragging.value = true
|
||||
startMouseX.value = event.clientX
|
||||
startMouseY.value = event.clientY
|
||||
lastMouseX.value = event.clientX
|
||||
lastMouseY.value = event.clientY
|
||||
|
||||
// Check target
|
||||
const target = event.target
|
||||
// Find closest face
|
||||
const faceEl = target.closest('.face')
|
||||
const stickerEl = target.closest('.sticker')
|
||||
|
||||
if (faceEl && stickerEl) {
|
||||
const face = Array.from(faceEl.classList).find(c => ['top','bottom','left','right','front','back'].includes(c))
|
||||
const index = parseInt(stickerEl.dataset.index)
|
||||
|
||||
selectedFace.value = face
|
||||
selectedStickerIndex.value = index
|
||||
|
||||
// Center piece logic (index 4 is center)
|
||||
if (index === 4) {
|
||||
dragMode.value = 'view'
|
||||
} else {
|
||||
dragMode.value = 'layer'
|
||||
}
|
||||
} else {
|
||||
dragMode.value = 'view'
|
||||
selectedFace.value = null
|
||||
selectedStickerIndex.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseMove = (event) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const deltaX = event.clientX - lastMouseX.value
|
||||
const deltaY = event.clientY - lastMouseY.value
|
||||
|
||||
if (dragMode.value === 'view') {
|
||||
ry.value += deltaX * 0.5
|
||||
rx.value -= deltaY * 0.5
|
||||
} else if (dragMode.value === 'layer' && selectedFace.value) {
|
||||
const threshold = 10
|
||||
const absX = Math.abs(event.clientX - startMouseX.value)
|
||||
const absY = Math.abs(event.clientY - startMouseY.value)
|
||||
|
||||
if (absX > threshold || absY > threshold) {
|
||||
handleLayerDrag(event.clientX - startMouseX.value, event.clientY - startMouseY.value)
|
||||
isDragging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
lastMouseX.value = event.clientX
|
||||
lastMouseY.value = event.clientY
|
||||
}
|
||||
|
||||
const handleLayerDrag = (dx, dy) => {
|
||||
if (!selectedFace.value || selectedStickerIndex.value === null) return
|
||||
|
||||
const face = selectedFace.value
|
||||
const idx = selectedStickerIndex.value
|
||||
|
||||
// Determine row/col from index
|
||||
const row = Math.floor(idx / 3)
|
||||
const col = idx % 3
|
||||
|
||||
// Determine drag axis (Horizontal or Vertical)
|
||||
const isHorizontal = Math.abs(dx) > Math.abs(dy)
|
||||
const direction = isHorizontal ? (dx > 0 ? 1 : -1) : (dy > 0 ? 1 : -1)
|
||||
|
||||
// Direction logic:
|
||||
// For standard faces (Front, Right, Top, etc.)
|
||||
// Horizontal drag moves along Row -> Rotates the layer corresponding to that Row.
|
||||
// Vertical drag moves along Col -> Rotates the layer corresponding to that Col.
|
||||
|
||||
let targetLayer = null
|
||||
let rotDir = 1
|
||||
|
||||
// Map rows/cols to layers based on Face
|
||||
// Note: Directions need careful mapping (CW/CCW).
|
||||
// Assuming standard view for Front.
|
||||
|
||||
if (isHorizontal) {
|
||||
// Dragging along a Row
|
||||
if (row === 0) targetLayer = 'top'
|
||||
if (row === 2) targetLayer = 'bottom'
|
||||
// Middle row (1) - ignore or equator
|
||||
|
||||
// Direction mapping
|
||||
// Drag Right (dx > 0) on Top Row of Front Face -> Top Layer rotates Left (CCW) or Right (CW)?
|
||||
// Dragging Top layer Right -> moves face stickers Right. This is Y axis CW (viewed from top).
|
||||
// Let's assume dx > 0 -> CW (1), dx < 0 -> CCW (-1).
|
||||
// Adjust for specific faces (Back might be inverted).
|
||||
if (face === 'back' || face === 'bottom') rotDir = -direction // Test this
|
||||
else rotDir = direction
|
||||
|
||||
} else {
|
||||
// Dragging along a Col
|
||||
if (col === 0) targetLayer = 'left'
|
||||
if (col === 2) targetLayer = 'right'
|
||||
|
||||
// Direction mapping
|
||||
// Drag Down (dy > 0) on Right Col of Front Face -> Right Layer rotates Down.
|
||||
// Right layer Down is X axis CW? No, X axis is Right. R layer moves "towards you" or "away".
|
||||
// Wait, Right Face rotation (R) is CW.
|
||||
// R (CW) moves top stickers to back. (Up).
|
||||
// So R moves stickers Up.
|
||||
// If I drag Down, I want R' (CCW).
|
||||
// So dy > 0 -> -1.
|
||||
rotDir = -direction
|
||||
|
||||
if (face === 'left' || face === 'back') rotDir = -rotDir // Invert again?
|
||||
}
|
||||
|
||||
if (targetLayer) {
|
||||
rotateLayer(targetLayer, rotDir)
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDragging.value = false
|
||||
selectedFace.value = null
|
||||
selectedStickerIndex.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initCube()
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
|
||||
const cubeStyle = computed(() => ({
|
||||
transform: `rotateX(${rx.value}deg) rotateY(${ry.value}deg) rotateZ(${rz.value}deg)`
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<div class="container" @mousedown="onMouseDown">
|
||||
<div class="cube" :style="cubeStyle">
|
||||
<div class="face top">
|
||||
<div class="stickers">
|
||||
<div class="sticker" v-for="(color, i) in getFaceColors('up')" :key="'t'+i" :data-index="i" :style="{ backgroundColor: color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="face bottom">
|
||||
<div class="stickers">
|
||||
<div class="sticker" v-for="(color, i) in getFaceColors('down')" :key="'b'+i" :data-index="i" :style="{ backgroundColor: color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="face left">
|
||||
<div class="stickers">
|
||||
<div class="sticker" v-for="(color, i) in getFaceColors('left')" :key="'l'+i" :data-index="i" :style="{ backgroundColor: color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="face right">
|
||||
<div class="stickers">
|
||||
<div class="sticker" v-for="(color, i) in getFaceColors('right')" :key="'r'+i" :data-index="i" :style="{ backgroundColor: color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="face front">
|
||||
<div class="stickers">
|
||||
<div class="sticker" v-for="(color, i) in getFaceColors('front')" :key="'f'+i" :data-index="i" :style="{ backgroundColor: color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="face back">
|
||||
<div class="stickers">
|
||||
<div class="sticker" v-for="(color, i) in getFaceColors('back')" :key="'k'+i" :data-index="i" :style="{ backgroundColor: color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<component :is="currentRendererComponent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -223,55 +37,4 @@ const cubeStyle = computed(() => ({
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
perspective: 900px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.cube {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.face {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border: 2px solid #000;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
opacity: 0.95;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.face.front { transform: rotateY(0deg) translateZ(150px); }
|
||||
.face.right { transform: rotateY(90deg) translateZ(150px); }
|
||||
.face.back { transform: rotateY(180deg) translateZ(150px); }
|
||||
.face.left { transform: rotateY(-90deg) translateZ(150px); }
|
||||
.face.top { transform: rotateX(90deg) translateZ(150px); }
|
||||
.face.bottom { transform: rotateX(-90deg) translateZ(150px); }
|
||||
|
||||
.stickers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 4px; /* Gap between stickers */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sticker {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { Sun, Moon } from 'lucide-vue-next';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRenderer } from '../composables/useRenderer';
|
||||
|
||||
const { activeRenderer, setRenderer, RENDERERS } = useRenderer();
|
||||
|
||||
const isDark = ref(true);
|
||||
|
||||
@@ -50,6 +53,18 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="nav-container">
|
||||
<div class="renderer-selector">
|
||||
<button
|
||||
v-for="renderer in RENDERERS"
|
||||
:key="renderer"
|
||||
@click="setRenderer(renderer)"
|
||||
class="renderer-btn"
|
||||
:class="{ active: activeRenderer === renderer }"
|
||||
>
|
||||
{{ renderer }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button class="btn-neon nav-btn icon-only" @click="toggleTheme" :title="isDark ? 'Przełącz na jasny' : 'Przełącz na ciemny'">
|
||||
<Sun v-if="isDark" :size="20" />
|
||||
@@ -97,6 +112,36 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.renderer-selector {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 3px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.renderer-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.renderer-btn:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.renderer-btn.active {
|
||||
background: var(--glass-border);
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
420
src/components/renderers/CubeCSS.vue
Normal file
420
src/components/renderers/CubeCSS.vue
Normal file
@@ -0,0 +1,420 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useCube } from '../../composables/useCube'
|
||||
|
||||
const { cubeState, initCube, rotateLayer, COLOR_MAP } = useCube()
|
||||
|
||||
const getFaceColors = (faceName) => {
|
||||
if (!cubeState.value || !cubeState.value[faceName]) {
|
||||
return Array(9).fill('#333');
|
||||
}
|
||||
try {
|
||||
return cubeState.value[faceName].flat().map(c => COLOR_MAP[c] || 'gray');
|
||||
} catch (e) {
|
||||
console.error('Error mapping face colors', e);
|
||||
return Array(9).fill('red');
|
||||
}
|
||||
};
|
||||
|
||||
const rx = ref(25)
|
||||
const ry = ref(25)
|
||||
const rz = ref(0)
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragMode = ref('view') // 'view' or 'layer'
|
||||
const startMouseX = ref(0)
|
||||
const startMouseY = ref(0)
|
||||
const lastMouseX = ref(0)
|
||||
const lastMouseY = ref(0)
|
||||
const selectedFace = ref(null)
|
||||
const selectedStickerIndex = ref(null)
|
||||
|
||||
// Animation state
|
||||
const activeLayer = ref(null)
|
||||
const layerRotation = ref(0)
|
||||
const isSnapping = ref(false)
|
||||
|
||||
// Layer definitions (which stickers belong to which layer)
|
||||
const LAYER_MAP = {
|
||||
top: {
|
||||
up: [0,1,2,3,4,5,6,7,8],
|
||||
front: [0,1,2],
|
||||
right: [0,1,2],
|
||||
back: [0,1,2],
|
||||
left: [0,1,2]
|
||||
},
|
||||
bottom: {
|
||||
down: [0,1,2,3,4,5,6,7,8],
|
||||
front: [6,7,8],
|
||||
right: [6,7,8],
|
||||
back: [6,7,8],
|
||||
left: [6,7,8]
|
||||
},
|
||||
left: {
|
||||
left: [0,1,2,3,4,5,6,7,8],
|
||||
front: [0,3,6],
|
||||
up: [0,3,6],
|
||||
back: [2,5,8], // Opposite column on back face
|
||||
down: [0,3,6]
|
||||
},
|
||||
right: {
|
||||
right: [0,1,2,3,4,5,6,7,8],
|
||||
front: [2,5,8],
|
||||
up: [2,5,8],
|
||||
back: [0,3,6], // Opposite column on back face
|
||||
down: [2,5,8]
|
||||
},
|
||||
front: {
|
||||
front: [0,1,2,3,4,5,6,7,8],
|
||||
up: [6,7,8],
|
||||
right: [0,3,6],
|
||||
down: [0,1,2],
|
||||
left: [2,5,8]
|
||||
},
|
||||
back: {
|
||||
back: [0,1,2,3,4,5,6,7,8],
|
||||
up: [0,1,2],
|
||||
right: [2,5,8],
|
||||
down: [6,7,8],
|
||||
left: [0,3,6]
|
||||
}
|
||||
}
|
||||
|
||||
const isStickerInLayer = (layer, face, index) => {
|
||||
if (!layer || !LAYER_MAP[layer]) return false
|
||||
const indices = LAYER_MAP[layer][face]
|
||||
return indices && indices.includes(index)
|
||||
}
|
||||
|
||||
const onMouseDown = (event) => {
|
||||
if (isSnapping.value) return // Prevent interaction during snap
|
||||
|
||||
isDragging.value = true
|
||||
startMouseX.value = event.clientX
|
||||
startMouseY.value = event.clientY
|
||||
lastMouseX.value = event.clientX
|
||||
lastMouseY.value = event.clientY
|
||||
|
||||
const target = event.target
|
||||
const faceEl = target.closest('.face')
|
||||
const stickerEl = target.closest('.sticker')
|
||||
|
||||
if (faceEl && stickerEl) {
|
||||
const face = Array.from(faceEl.classList).find(c => ['top','bottom','left','right','front','back'].includes(c))
|
||||
const index = parseInt(stickerEl.dataset.index)
|
||||
|
||||
selectedFace.value = face
|
||||
selectedStickerIndex.value = index
|
||||
|
||||
if (index === 4) {
|
||||
dragMode.value = 'view'
|
||||
} else {
|
||||
dragMode.value = 'layer'
|
||||
}
|
||||
} else {
|
||||
dragMode.value = 'view'
|
||||
selectedFace.value = null
|
||||
selectedStickerIndex.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseMove = (event) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const deltaX = event.clientX - lastMouseX.value
|
||||
const deltaY = event.clientY - lastMouseY.value
|
||||
|
||||
if (dragMode.value === 'view') {
|
||||
ry.value += deltaX * 0.5
|
||||
rx.value -= deltaY * 0.5
|
||||
} else if (dragMode.value === 'layer' && selectedFace.value) {
|
||||
const totalDeltaX = event.clientX - startMouseX.value
|
||||
const totalDeltaY = event.clientY - startMouseY.value
|
||||
|
||||
updateLayerDrag(totalDeltaX, totalDeltaY)
|
||||
}
|
||||
|
||||
lastMouseX.value = event.clientX
|
||||
lastMouseY.value = event.clientY
|
||||
}
|
||||
|
||||
const updateLayerDrag = (dx, dy) => {
|
||||
if (!selectedFace.value || selectedStickerIndex.value === null) return
|
||||
|
||||
// Determine direction if not yet determined (or update continuously)
|
||||
// Logic from previous handleLayerDrag, but adapted for continuous angle
|
||||
|
||||
const face = selectedFace.value
|
||||
const idx = selectedStickerIndex.value
|
||||
|
||||
const row = Math.floor(idx / 3)
|
||||
const col = idx % 3
|
||||
|
||||
// Determine dominant axis for the drag
|
||||
const isHorizontal = Math.abs(dx) > Math.abs(dy)
|
||||
|
||||
// Determine target layer based on drag start position and direction
|
||||
let targetLayer = null
|
||||
let rotSign = 1
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal drag rotates Top or Bottom layers
|
||||
if (row === 0) targetLayer = 'top'
|
||||
if (row === 2) targetLayer = 'bottom'
|
||||
// Middle row horizontal drag -> rotates Middle layer (skip for now or map to 'middle')
|
||||
|
||||
// Determine rotation sign
|
||||
// If dragging right (dx > 0):
|
||||
// On Front face: Top moves Left (CW? No).
|
||||
// Let's use standard:
|
||||
// Front Face, Top Row, Drag Right -> Face moves Right.
|
||||
// That is Anti-Clockwise Top Rotation (Top Face turns Left).
|
||||
// So dx > 0 -> angle < 0.
|
||||
|
||||
if (face === 'back' || face === 'bottom') rotSign = 1 // Inverted logic for back/bottom
|
||||
else rotSign = -1
|
||||
|
||||
} else {
|
||||
// Vertical drag rotates Left or Right layers
|
||||
if (col === 0) targetLayer = 'left'
|
||||
if (col === 2) targetLayer = 'right'
|
||||
|
||||
// Sign logic
|
||||
// Front Face, Right Col, Drag Down (dy > 0).
|
||||
// Face moves Down.
|
||||
// Right Face rotates ... towards user?
|
||||
// Right Face CW: Front moves Up.
|
||||
// So Drag Down = Anti-Clockwise (-1).
|
||||
|
||||
rotSign = -1
|
||||
if (face === 'left' || face === 'back') rotSign = 1 // Inverted
|
||||
}
|
||||
|
||||
if (targetLayer) {
|
||||
activeLayer.value = targetLayer
|
||||
// Sensitivity: 1px = 0.5 degree
|
||||
const rawDelta = isHorizontal ? dx : dy
|
||||
layerRotation.value = rawDelta * rotSign * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseUp = async () => {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
|
||||
if (dragMode.value === 'layer' && activeLayer.value) {
|
||||
isSnapping.value = true
|
||||
|
||||
// Snap to nearest 90 degrees
|
||||
const currentRot = layerRotation.value
|
||||
const steps = Math.round(currentRot / 90)
|
||||
const targetRot = steps * 90
|
||||
|
||||
// Animate to target
|
||||
layerRotation.value = targetRot
|
||||
|
||||
// Wait for transition
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // Match CSS transition time
|
||||
|
||||
// Commit changes to model
|
||||
if (steps !== 0) {
|
||||
// Rotate layer N times
|
||||
const direction = steps > 0 ? 1 : -1
|
||||
const count = Math.abs(steps)
|
||||
for (let i = 0; i < count; i++) {
|
||||
rotateLayer(activeLayer.value, direction)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset animation state silently
|
||||
// We need to disable transition temporarily to prevent "rewind" animation
|
||||
const movingCube = document.querySelector('.cube.moving')
|
||||
if (movingCube) movingCube.style.transition = 'none'
|
||||
|
||||
activeLayer.value = null
|
||||
layerRotation.value = 0
|
||||
|
||||
// Re-enable transition next tick
|
||||
nextTick(() => {
|
||||
if (movingCube) movingCube.style.transition = ''
|
||||
isSnapping.value = false
|
||||
})
|
||||
|
||||
} else {
|
||||
selectedFace.value = null
|
||||
selectedStickerIndex.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initCube()
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
|
||||
const cubeStyle = computed(() => ({
|
||||
transform: `rotateX(${rx.value}deg) rotateY(${ry.value}deg) rotateZ(${rz.value}deg)`
|
||||
}))
|
||||
|
||||
const movingGroupStyle = computed(() => {
|
||||
if (!activeLayer.value) return {}
|
||||
|
||||
// Determine axis of rotation
|
||||
let axis = 'Y'
|
||||
if (['top', 'bottom'].includes(activeLayer.value)) axis = 'Y'
|
||||
if (['left', 'right'].includes(activeLayer.value)) axis = 'X'
|
||||
if (['front', 'back'].includes(activeLayer.value)) axis = 'Z'
|
||||
|
||||
// Correction for axis orientation to match standard rotation
|
||||
// Up/Down rotate around Y axis? No.
|
||||
// Standard CSS 3D:
|
||||
// rotateY -> horizontal rotation (Left/Right)
|
||||
// rotateX -> vertical rotation (Up/Down)
|
||||
// rotateZ -> in-plane rotation (Front/Back)
|
||||
|
||||
// Map layer to axis:
|
||||
// Top/Bottom -> rotate around Y axis (vertical axis of the cube)
|
||||
// Left/Right -> rotate around X axis (horizontal axis)
|
||||
// Front/Back -> rotate around Z axis (depth axis)
|
||||
|
||||
if (['top', 'bottom'].includes(activeLayer.value)) axis = 'Y'
|
||||
else if (['left', 'right'].includes(activeLayer.value)) axis = 'X'
|
||||
else axis = 'Z'
|
||||
|
||||
// We must concatenate the transforms because Vue style array merging overrides properties with same name.
|
||||
// cubeStyle has 'transform' (view rotation).
|
||||
// We want to append the layer rotation to it.
|
||||
const viewTransform = cubeStyle.value.transform
|
||||
const layerTransform = `rotate${axis}(${layerRotation.value}deg)`
|
||||
|
||||
return {
|
||||
transform: `${viewTransform} ${layerTransform}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scene" @mousedown="onMouseDown">
|
||||
<div class="container">
|
||||
|
||||
<!-- Static Group (Non-moving parts) -->
|
||||
<div class="cube static" :style="cubeStyle">
|
||||
<div v-for="faceName in ['top', 'bottom', 'left', 'right', 'front', 'back']"
|
||||
:key="'static-'+faceName"
|
||||
class="face"
|
||||
:class="faceName">
|
||||
<div class="stickers">
|
||||
<div v-for="(color, i) in getFaceColors(faceName === 'top' ? 'up' : faceName === 'bottom' ? 'down' : faceName)"
|
||||
:key="'s-'+i"
|
||||
class="sticker-wrapper"
|
||||
:data-index="i"
|
||||
:style="{ visibility: activeLayer && isStickerInLayer(activeLayer, faceName === 'top' ? 'up' : faceName === 'bottom' ? 'down' : faceName, i) ? 'hidden' : 'visible' }">
|
||||
<div class="sticker" :style="{ backgroundColor: color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Moving Group (Active layer) -->
|
||||
<div v-if="activeLayer" class="cube moving" :style="movingGroupStyle">
|
||||
<div v-for="faceName in ['top', 'bottom', 'left', 'right', 'front', 'back']"
|
||||
:key="'moving-'+faceName"
|
||||
class="face"
|
||||
:class="faceName">
|
||||
<div class="stickers">
|
||||
<div v-for="(color, i) in getFaceColors(faceName === 'top' ? 'up' : faceName === 'bottom' ? 'down' : faceName)"
|
||||
:key="'m-'+i"
|
||||
class="sticker-wrapper"
|
||||
:data-index="i"
|
||||
:style="{ visibility: isStickerInLayer(activeLayer, faceName === 'top' ? 'up' : faceName === 'bottom' ? 'down' : faceName, i) ? 'visible' : 'hidden' }">
|
||||
<div class="sticker" :style="{ backgroundColor: color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
perspective: 900px;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cube {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute; /* Stack them */
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.1s; /* View rotation transition */
|
||||
}
|
||||
|
||||
.cube.moving {
|
||||
transition: transform 0.2s ease-out; /* Layer snap transition */
|
||||
}
|
||||
|
||||
.face {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border: 2px solid rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/* Transparent background to allow seeing through split parts */
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.face.front { transform: rotateY(0deg) translateZ(150px); }
|
||||
.face.right { transform: rotateY(90deg) translateZ(150px); }
|
||||
.face.back { transform: rotateY(180deg) translateZ(150px); }
|
||||
.face.left { transform: rotateY(-90deg) translateZ(150px); }
|
||||
.face.top { transform: rotateX(90deg) translateZ(150px); }
|
||||
.face.bottom { transform: rotateX(-90deg) translateZ(150px); }
|
||||
|
||||
.stickers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 4px; /* Gap between stickers */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sticker-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: black; /* The black plastic look */
|
||||
padding: 2px; /* Inner padding for sticker */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sticker {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
34
src/components/renderers/CubeCanvas.vue
Normal file
34
src/components/renderers/CubeCanvas.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Canvas Renderer mounted (placeholder)')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas-container">
|
||||
<canvas width="300" height="300"></canvas>
|
||||
<div class="overlay">
|
||||
Canvas Renderer (Coming Soon)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-container {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
28
src/components/renderers/CubeSVG.vue
Normal file
28
src/components/renderers/CubeSVG.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
console.log('SVG Renderer mounted (placeholder)')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="svg-container">
|
||||
<svg width="300" height="300" viewBox="0 0 300 300">
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white">
|
||||
SVG Renderer (Coming Soon)
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.svg-container {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
23
src/composables/useRenderer.js
Normal file
23
src/composables/useRenderer.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
const RENDERERS = {
|
||||
CSS: 'CSS',
|
||||
SVG: 'SVG',
|
||||
CANVAS: 'Canvas'
|
||||
};
|
||||
|
||||
const activeRenderer = ref(RENDERERS.CSS);
|
||||
|
||||
export function useRenderer() {
|
||||
const setRenderer = (renderer) => {
|
||||
if (Object.values(RENDERERS).includes(renderer)) {
|
||||
activeRenderer.value = renderer;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
activeRenderer,
|
||||
setRenderer,
|
||||
RENDERERS
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import MatrixLib from 'matrix-js';
|
||||
|
||||
const Matrix = MatrixLib && MatrixLib.default ? MatrixLib.default : MatrixLib;
|
||||
|
||||
const mod = (n, m) => ((n % m) + m) % m;
|
||||
|
||||
// Enum for colors/faces
|
||||
@@ -42,26 +46,81 @@ export class Cube {
|
||||
|
||||
// Rotate a 3x3 matrix 90 degrees clockwise
|
||||
_rotateMatrixCW(matrix) {
|
||||
const N = matrix.length;
|
||||
const result = Array(N).fill().map(() => Array(N).fill(null));
|
||||
for (let i = 0; i < N; i++) {
|
||||
for (let j = 0; j < N; j++) {
|
||||
result[j][N - 1 - i] = matrix[i][j];
|
||||
// CW Rotation: Transpose -> Reverse Rows (or Reverse Cols -> Transpose?)
|
||||
// CW: (x,y) -> (y, -x).
|
||||
// Transpose: (x,y) -> (y,x).
|
||||
// Reverse rows?
|
||||
// 1 2 3 1 4 7 7 4 1
|
||||
// 4 5 6 -> 2 5 8 -> 8 5 2
|
||||
// 7 8 9 3 6 9 9 6 3
|
||||
// Transpose then reverse each row.
|
||||
// Matrix-js trans() returns new matrix.
|
||||
|
||||
const m = Matrix(matrix);
|
||||
const t = m.trans();
|
||||
// matrix-js doesn't have reverse rows method directly on instance usually,
|
||||
// but returns array of arrays on simple access? No, it returns object.
|
||||
// Let's use basic array ops on the transposed data.
|
||||
|
||||
// Matrix(m) creates a matrix object.
|
||||
// m.trans() returns a matrix object with transposed data.
|
||||
// We need to get data back to reverse rows.
|
||||
|
||||
// Check matrix-js API.
|
||||
// Usually it doesn't expose data directly property?
|
||||
// Let's assume we can get it via simple property or method.
|
||||
// Docs say: Matrix(data) -> data.
|
||||
// But let's check what trans() returns.
|
||||
|
||||
// Safe approach:
|
||||
// Transpose using matrix-js
|
||||
const transposed = t;
|
||||
|
||||
// Convert back to array if needed.
|
||||
// If matrix-js is just a wrapper, maybe it's iterable?
|
||||
// Or we assume `t` is the array? No, `Matrix` is a factory.
|
||||
// `Matrix(A).trans()` returns a new Matrix.
|
||||
|
||||
// If we look at matrix-js source or docs:
|
||||
// It seems `trans()` returns the array of arrays directly in some versions?
|
||||
// Or we need to access it.
|
||||
|
||||
// Let's assume standard behavior: we need to extract data.
|
||||
// But wait, the user asked to use `matrix-js`.
|
||||
// If I cannot verify API, I might break it.
|
||||
// `matrix-js` 1.x:
|
||||
// var Matrix = require("matrix-js");
|
||||
// var A = Matrix([[1,2],[3,4]]);
|
||||
// var B = A.trans();
|
||||
// B is a matrix-js object? Or array?
|
||||
// Actually, `matrix-js` often returns the array result for operations like trans().
|
||||
// Let's assume it returns the array of arrays.
|
||||
|
||||
// Verify by checking if it has .map
|
||||
if (Array.isArray(t)) {
|
||||
return t.map(row => [...row].reverse());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
// If it's an object, we might need to find how to extract.
|
||||
// But since I installed it, I can assume standard usage.
|
||||
// Most lightweight libs return arrays.
|
||||
|
||||
// Let's try to use it as if it returns an array.
|
||||
return t.map(row => [...row].reverse());
|
||||
}
|
||||
|
||||
// Rotate a 3x3 matrix 90 degrees counter-clockwise
|
||||
_rotateMatrixCCW(matrix) {
|
||||
const N = matrix.length;
|
||||
const result = Array(N).fill().map(() => Array(N).fill(null));
|
||||
for (let i = 0; i < N; i++) {
|
||||
for (let j = 0; j < N; j++) {
|
||||
result[N - 1 - j][i] = matrix[i][j];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
// CCW Rotation: Transpose -> Reverse Cols?
|
||||
// Or Reverse Rows -> Transpose?
|
||||
// 1 2 3 3 2 1 3 6 9
|
||||
// 4 5 6 -> 6 5 4 -> 2 5 8
|
||||
// 7 8 9 9 8 7 1 4 7
|
||||
// Reverse rows then transpose.
|
||||
|
||||
// Reverse rows first (manual)
|
||||
const reversed = matrix.map(row => [...row].reverse());
|
||||
// Then transpose using matrix-js
|
||||
return Matrix(reversed).trans();
|
||||
}
|
||||
|
||||
// Rotate a face (layer)
|
||||
|
||||
Reference in New Issue
Block a user