fix: invert rotation logic and add debug tools

This commit is contained in:
2026-02-16 03:19:02 +01:00
parent 140100a535
commit c773ff8876
11 changed files with 1407 additions and 642 deletions

View File

@@ -0,0 +1,137 @@
<script setup>
import { useDebug } from '../composables/useDebug'
import { ref } from 'vue'
const { settings } = useDebug()
const isOpen = ref(true)
const toggle = () => isOpen.value = !isOpen.value
</script>
<template>
<div class="debug-panel" :class="{ open: isOpen }">
<div class="header" @click="toggle">
<span>🛠 Debug Config</span>
<span>{{ isOpen ? '▼' : '▲' }}</span>
</div>
<div v-if="isOpen" class="content">
<div class="section">
<h3>View Rotation</h3>
<label>
<input type="checkbox" v-model="settings.viewRotation.invertX"> Invert X (Up/Down)
</label>
<label>
<input type="checkbox" v-model="settings.viewRotation.invertY"> Invert Y (Left/Right)
</label>
<label>
Speed: <input type="number" step="0.1" v-model="settings.viewRotation.speed">
</label>
</div>
<div class="section">
<h3>Drag Mappings (Sign)</h3>
<p class="hint">Adjust signs (-1 or 1) to correct drag direction</p>
<div class="face-group" v-for="(val, face) in settings.dragMapping" :key="face">
<strong>{{ face.toUpperCase() }}</strong>
<div class="controls">
<label>X: <input type="number" :step="2" :min="-1" :max="1" v-model="settings.dragMapping[face].x"></label>
<label>Y: <input type="number" :step="2" :min="-1" :max="1" v-model="settings.dragMapping[face].y"></label>
</div>
</div>
</div>
<div class="section">
<h3>Physics</h3>
<label>
<input type="checkbox" v-model="settings.physics.enabled"> Inertia & Snap
</label>
</div>
</div>
</div>
</template>
<style scoped>
.debug-panel {
position: fixed;
top: 10px;
right: 10px;
width: 250px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
z-index: 9999;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.header {
padding: 10px;
background: #333;
border-radius: 8px 8px 0 0;
cursor: pointer;
display: flex;
justify-content: space-between;
font-weight: bold;
user-select: none;
}
.content {
padding: 10px;
overflow-y: auto;
}
.section {
margin-bottom: 15px;
border-bottom: 1px solid #444;
padding-bottom: 10px;
}
h3 {
margin: 0 0 8px 0;
color: #aaa;
font-size: 11px;
text-transform: uppercase;
}
label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
cursor: pointer;
}
input[type="number"] {
width: 40px;
background: #222;
border: 1px solid #444;
color: #fff;
padding: 2px;
}
.face-group {
margin-bottom: 8px;
background: #222;
padding: 5px;
border-radius: 4px;
}
.controls {
display: flex;
gap: 10px;
margin-top: 4px;
}
.hint {
font-size: 10px;
color: #888;
margin-bottom: 5px;
}
</style>

View File

@@ -0,0 +1,188 @@
<script setup>
import { ref } from 'vue'
import { useInteractionLogger } from '../composables/useInteractionLogger'
const { logs, isRecording, clearLogs, getRecentLogsForAnalysis } = useInteractionLogger()
const isOpen = ref(false)
const copied = ref(false)
const toggle = () => isOpen.value = !isOpen.value
const copyReport = async () => {
const report = getRecentLogsForAnalysis(50)
const context = `
### User Interaction Report
Please analyze the following interaction logs to identify the issue.
Focus on: Drag direction, Active Layer, Rotation Mapping, and State changes.
\`\`\`json
${report}
\`\`\`
`
try {
await navigator.clipboard.writeText(context)
copied.value = true
setTimeout(() => copied.value = false, 2000)
} catch (err) {
console.error('Failed to copy logs', err)
alert('Failed to copy to clipboard. Check console.')
}
}
</script>
<template>
<div class="interaction-replay">
<div class="header" @click="toggle" :class="{ recording: isRecording }">
<span class="indicator"></span>
<span>Logger ({{ logs.length }})</span>
</div>
<div v-if="isOpen" class="panel">
<div class="actions">
<button @click="copyReport" :class="{ success: copied }">
{{ copied ? 'Copied!' : '📋 Copy Report for AI' }}
</button>
<button @click="clearLogs" class="secondary">Clear</button>
<label>
<input type="checkbox" v-model="isRecording"> Rec
</label>
</div>
<div class="log-list">
<div v-for="log in logs.slice().reverse()" :key="log.id" class="log-item">
<span class="time">{{ new Date(log.timestamp).toISOString().substr(14, 9) }}</span>
<span class="type" :class="log.type">{{ log.type }}</span>
<pre class="data">{{ log.data }}</pre>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.interaction-replay {
position: fixed;
bottom: 10px;
right: 10px;
z-index: 10000;
font-family: monospace;
font-size: 12px;
}
.header {
background: #222;
color: #fff;
padding: 8px 12px;
border-radius: 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border: 1px solid #444;
}
.header.recording .indicator {
color: #ff4444;
animation: pulse 1.5s infinite;
}
.indicator {
color: #666;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.panel {
position: absolute;
bottom: 40px;
right: 0;
width: 350px;
height: 400px;
background: rgba(0, 0, 0, 0.9);
border-radius: 8px;
border: 1px solid #444;
display: flex;
flex-direction: column;
overflow: hidden;
}
.actions {
padding: 10px;
border-bottom: 1px solid #444;
display: flex;
gap: 8px;
background: #1a1a1a;
align-items: center;
}
button {
flex: 1;
padding: 6px;
background: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button.success {
background: #28a745;
}
button.secondary {
background: #444;
flex: 0 0 60px;
}
label {
color: #fff;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.log-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.log-item {
margin-bottom: 8px;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.time {
color: #666;
margin-right: 8px;
}
.type {
font-weight: bold;
padding: 2px 4px;
border-radius: 2px;
margin-right: 8px;
}
.type.drag-start { color: #4fc3f7; }
.type.drag-update { color: #ffd54f; }
.type.drag-end { color: #81c784; }
.type.rotation { color: #ba68c8; }
.data {
margin: 4px 0 0 0;
color: #aaa;
font-size: 10px;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -1,11 +1,13 @@
<script setup>
import { computed } from 'vue'
import { useRenderer } from '../composables/useRenderer'
import { useCube } from '../composables/useCube'
import CubeCSS from './renderers/CubeCSS.vue'
import CubeSVG from './renderers/CubeSVG.vue'
import CubeCanvas from './renderers/CubeCanvas.vue'
const { activeRenderer, RENDERERS } = useRenderer()
const { cubeState } = useCube()
const currentRendererComponent = computed(() => {
switch (activeRenderer.value) {
@@ -19,22 +21,89 @@ const currentRendererComponent = computed(() => {
return CubeCSS
}
})
const formattedState = computed(() => {
if (!cubeState.value) return '{}'
// Custom formatter to keep faces compact
const s = cubeState.value
const faces = Object.keys(s)
// Helper to shorten colors
const shortColor = (c) => c && typeof c === 'string' ? c[0].toUpperCase() : '-'
let out = '{\n'
faces.forEach((face, i) => {
const matrix = s[face]
// Format as ["WWW", "WWW", "WWW"]
const rows = matrix.map(row => `"${row.map(shortColor).join('')}"`).join(', ')
out += ` "${face}": [ ${rows} ]`
if (i < faces.length - 1) out += ','
out += '\n'
})
out += '}'
return out
})
</script>
<template>
<div class="wrapper">
<component :is="currentRendererComponent" />
<div class="renderer-container">
<component :is="currentRendererComponent" />
</div>
<div class="state-panel">
<h3>Cube State</h3>
<pre>{{ formattedState }}</pre>
</div>
</div>
</template>
<style scoped>
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
flex-direction: row;
align-items: flex-start;
justify-content: center;
gap: 2rem;
width: 100%;
height: 100%;
padding: 2rem;
box-sizing: border-box;
}
.renderer-container {
flex: 2;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-width: 300px;
}
.state-panel {
flex: 1;
max-width: 400px;
background: rgba(0, 0, 0, 0.05);
padding: 1rem;
border-radius: 8px;
max-height: 80vh;
overflow-y: auto;
font-family: monospace;
font-size: 0.8rem;
border: 1px solid rgba(0, 0, 0, 0.1);
}
h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.2rem;
color: #333;
}
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -1,8 +1,12 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useCube } from '../../composables/useCube'
import { useDebug } from '../../composables/useDebug'
import { useInteractionLogger } from '../../composables/useInteractionLogger'
const { cubeState, initCube, rotateLayer, COLOR_MAP, FACES } = useCube()
const { cubies, initCube, rotateLayer, FACES } = useCube()
const { settings: debugSettings } = useDebug()
const { addLog } = useInteractionLogger()
// --- State ---
const rx = ref(25)
@@ -19,173 +23,17 @@ const selectedCubieId = ref(null) // ID of the cubie where drag started
const selectedFaceNormal = ref(null) // Normal vector of the face clicked
// Animation state
const activeLayer = ref(null) // { axis: 'x'|'y'|'z', index: -1|0|1 }
const layerRotation = ref(0)
const isSnapping = ref(false)
const velocity = ref(0)
const lastTime = ref(0)
const rafId = ref(null)
// Cubies Model
// We represent the cube as 27 independent cubies.
// Each cubie has a current position (x, y, z) in grid coordinates [-1, 0, 1].
// And a rotation matrix (or simplified orientation).
// Actually, for CSS rendering, we can just keep track of their current (x,y,z) and applying transforms.
// But to match `useCube` state (which is color based), we need to map colors to cubies.
//
// Alternative:
// `useCube` maintains the logical state of colors on faces.
// To render 27 cubies that MOVE, we need to know which color belongs to which face of which cubie.
//
// Mapping:
// 27 Cubies. ID: 0..26.
// Position: x,y,z in {-1, 0, 1}.
//
// Colors:
// A cubie at (x,y,z) exposes faces if x/y/z is +/- 1.
// e.g. (1, 1, 1) is Right-Top-Front corner.
// It has 3 colored faces: Right, Top, Front.
// We need to fetch the color from `cubeState` at the correct indices.
//
// `cubeState` is organized by Faces.
// Front Face is a 3x3 matrix.
// (0,0) is Top-Left of Front Face.
// Front Face covers z=1 plane.
// x goes -1 (Left) to 1 (Right).
// y goes 1 (Top) to -1 (Bottom).
//
// Let's define the 27 cubies.
const cubies = ref([])
const initCubies = () => {
const newCubies = []
let id = 0
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
newCubies.push({
id: id++,
x, y, z, // Current grid position
// Store initial rotation or accumulate transform?
// Simplest is to accumulate rotation transforms for the cubie div.
// But for logic, we update x,y,z after snap.
transform: ''
})
}
}
}
cubies.value = newCubies
}
// Map logical face colors to cubie faces
// We need a function that given a cubie (x,y,z) returns the colors of its 6 faces.
// If a face is internal, color is black (or null).
const getCubieFaces = (cubie) => {
const { x, y, z } = cubie
const faces = {}
// Helper to map grid (x,y) to Matrix indices (row, col)
// Grid: x (-1..1), y (-1..1).
// Matrix: row (0..2), col (0..2).
//
// Face UP (y=1). z: Back(-1)..Front(1). x: Left(-1)..Right(1).
// Up Matrix: row 0 is Back, row 2 is Front. col 0 is Left, col 2 is Right.
// So: row = z + 1? No.
// z=-1 -> row 0. z=0 -> row 1. z=1 -> row 2. Yes.
// x=-1 -> col 0. x=0 -> col 1. x=1 -> col 2. Yes.
if (y === 1) {
const row = z + 1
const col = x + 1
faces.up = getColor(FACES.UP, row, col)
}
// Face DOWN (y=-1). z: Back(-1)..Front(1). x: Left(-1)..Right(1).
// Down Matrix: row 0 is Front, row 2 is Back. col 0 is Left, col 2 is Right.
// Wait, check standard mapping in `Cube.js` or standard rubik.
// Usually unfolding:
// Up: Back row is top.
// Down: Front row is top?
// Let's assume standard intuitive mapping:
// Down Face viewed from bottom.
// Row 0 is Front (top of view).
// z=1 -> row 0. z=-1 -> row 2.
// So row = 1 - z.
// x=-1 -> col 0. x=1 -> col 2.
if (y === -1) {
const row = 1 - z
const col = x + 1
faces.down = getColor(FACES.DOWN, row, col)
}
// Face FRONT (z=1). y: Top(1)..Bottom(-1). x: Left(-1)..Right(1).
// Matrix: row 0 is Top.
// y=1 -> row 0. y=-1 -> row 2.
// row = 1 - y.
// x=-1 -> col 0.
if (z === 1) {
const row = 1 - y
const col = x + 1
faces.front = getColor(FACES.FRONT, row, col)
}
// Face BACK (z=-1). y: Top(1)..Bottom(-1). x: Right(1)..Left(-1)?
// Back Face viewed from Back.
// Left side of view is Cube Right (x=1).
// Right side of view is Cube Left (x=-1).
// Matrix: row 0 is Top.
// y=1 -> row 0.
// col 0 (Left of view) -> x=1.
// col 2 (Right of view) -> x=-1.
// col = 1 - x.
if (z === -1) {
const row = 1 - y
const col = 1 - x
faces.back = getColor(FACES.BACK, row, col)
}
// Face RIGHT (x=1). y: Top(1)..Bottom(-1). z: Front(1)..Back(-1).
// Right Face viewed from Right.
// Left side of view is Front (z=1).
// Right side of view is Back (z=-1).
// Matrix: row 0 is Top.
// y=1 -> row 0.
// col 0 -> z=1.
// col 2 -> z=-1.
// col = 1 - z.
if (x === 1) {
const row = 1 - y
const col = 1 - z
faces.right = getColor(FACES.RIGHT, row, col)
}
// Face LEFT (x=-1). y: Top(1)..Bottom(-1). z: Back(-1)..Front(1).
// Left Face viewed from Left.
// Left side of view is Back (z=-1).
// Right side of view is Front (z=1).
// Matrix: row 0 is Top.
// y=1 -> row 0.
// col 0 -> z=-1.
// col 2 -> z=1.
// col = z + 1.
if (x === -1) {
const row = 1 - y
const col = z + 1
faces.left = getColor(FACES.LEFT, row, col)
}
return faces
}
const getColor = (face, row, col) => {
if (!cubeState.value || !cubeState.value[face]) return 'black'
const colorIndex = cubeState.value[face][row][col]
return COLOR_MAP[colorIndex] || 'black'
}
const activeLayer = ref(null) // { axis: 'x'|'y'|'z', index: -1|0|1 }
const layerRotation = ref(0)
const isSnapping = ref(false)
const velocity = ref(0)
const lastTime = ref(0)
const rafId = ref(null)
// Mouse Interaction
const onMouseDown = (event) => {
if (isSnapping.value) return
isDragging.value = true
startMouseX.value = event.clientX
startMouseY.value = event.clientY
@@ -193,128 +41,222 @@ const onMouseDown = (event) => {
lastMouseY.value = event.clientY
lastTime.value = performance.now()
velocity.value = 0 // Reset velocity
const target = event.target
const stickerEl = target.closest('.sticker-face')
if (stickerEl) {
// Clicked on a cubie face
const cubieId = parseInt(stickerEl.dataset.cubieId)
const faceName = stickerEl.dataset.face
selectedCubieId.value = cubieId
selectedFaceNormal.value = faceName // 'up', 'down', etc.
// Check if it's a center face (implies View Drag)?
// User wants drag to rotate layers if grabbing edge/corner.
// Center face of the whole cube? No, center face of a side.
// If I grab the center sticker of Front Face, I might want to rotate View OR Front Face?
// User said: "jedynie centralny element kostki dragowany, bedzie ja po prostu obracal"
// So Center Sticker -> View Drag.
// Center Sticker is when x,y,z has two zeros? No.
// Center of Front Face: (0,0,1).
// Edge: (1,0,1). Corner: (1,1,1).
const cubie = cubies.value.find(c => c.id === cubieId)
const isCenter = (Math.abs(cubie.x) + Math.abs(cubie.y) + Math.abs(cubie.z)) === 1
if (isCenter) {
dragMode.value = 'view'
document.body.style.cursor = 'move'
addLog('drag-start', { mode: 'view', cubieId, face: faceName })
} else {
dragMode.value = 'layer'
document.body.style.cursor = 'grab'
addLog('drag-start', { mode: 'layer', cubieId, face: faceName })
}
} else {
dragMode.value = 'view'
selectedCubieId.value = null
document.body.style.cursor = 'move'
addLog('drag-start', { mode: 'view', target: 'background' })
}
}
const onMouseMove = (event) => {
if (!isDragging.value) return
if (dragMode.value === 'layer') {
document.body.style.cursor = 'grabbing'
}
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
velocity.value = 0 // Reset velocity for view drag (or track it separately if needed)
const s = debugSettings.viewRotation
const speed = s.speed || 0.5
// Use debug settings for direction
ry.value += deltaX * speed * (s.invertY ? -1 : 1)
rx.value += deltaY * speed * (s.invertX ? -1 : 1)
velocity.value = 0
} else if (dragMode.value === 'layer' && selectedCubieId.value !== null) {
const totalDeltaX = event.clientX - startMouseX.value
const totalDeltaY = event.clientY - startMouseY.value
// Calculate velocity
const now = performance.now()
const dt = now - lastTime.value
lastTime.value = now
// We only care about velocity of rotation, so we calculate it inside updateLayerDrag?
// Or we track mouse velocity here.
// Let's track rotation velocity in updateLayerDrag to be accurate with axis mapping.
updateLayerDrag(totalDeltaX, totalDeltaY, dt)
}
lastMouseX.value = event.clientX
lastMouseY.value = event.clientY
}
const getRotationMapping = (face) => {
const m = debugSettings.dragMapping[face]
// Default structure but with signs from debug settings
const defaults = {
[FACES.FRONT]: [
{ axis: 'x', rotAxis: 'y', sign: m ? m.x : -1 },
{ axis: 'y', rotAxis: 'x', sign: m ? m.y : -1 }
],
[FACES.BACK]: [
{ axis: 'x', rotAxis: 'y', sign: m ? m.x : 1 },
{ axis: 'y', rotAxis: 'x', sign: m ? m.y : 1 }
],
[FACES.RIGHT]: [
{ axis: 'z', rotAxis: 'y', sign: m ? m.x : -1 },
{ axis: 'y', rotAxis: 'z', sign: m ? m.y : 1 }
],
[FACES.LEFT]: [
{ axis: 'z', rotAxis: 'y', sign: m ? m.x : -1 },
{ axis: 'y', rotAxis: 'z', sign: m ? m.y : -1 }
],
[FACES.UP]: [
{ axis: 'x', rotAxis: 'z', sign: m ? m.x : 1 },
{ axis: 'z', rotAxis: 'x', sign: m ? m.y : 1 }
],
[FACES.DOWN]: [
{ axis: 'x', rotAxis: 'z', sign: m ? m.x : -1 },
{ axis: 'z', rotAxis: 'x', sign: m ? m.y : -1 }
]
}
return defaults[face]
}
const ROTATION_MAPPING = {
// Kept for reference or initial state if needed, but we use getRotationMapping now
}
// Helper to project 3D vector to 2D screen space based on current view rotation
const projectVector = (vector) => {
const radX = rx.value * Math.PI / 180
const radY = ry.value * Math.PI / 180
const radZ = rz.value * Math.PI / 180
const { x, y, z } = vector
// v1 = Rz * v
let x1 = x * Math.cos(radZ) - y * Math.sin(radZ)
let y1 = x * Math.sin(radZ) + y * Math.cos(radZ)
let z1 = z
// v2 = Ry * v1
let x2 = x1 * Math.cos(radY) + z1 * Math.sin(radY)
let y2 = y1
let z2 = -x1 * Math.sin(radY) + z1 * Math.cos(radY)
// v3 = Rx * v2
let x3 = x2
let y3 = y2 * Math.cos(radX) - z2 * Math.sin(radX)
let z3 = y2 * Math.sin(radX) + z2 * Math.cos(radX)
return { x: x3, y: y3 }
}
const updateLayerDrag = (dx, dy, dt) => {
// Determine rotation axis and direction based on drag vector and clicked face
const cubie = cubies.value.find(c => c.id === selectedCubieId.value)
if (!cubie) return
// Need to map 2D drag to 3D axis.
// Face Normals:
// Front: Z. Right: X. Up: Y.
const face = selectedFaceNormal.value
let axis = null
let sign = 1
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
const isHorizontal = absDx > absDy
// Logic:
if (face === 'front' || face === 'back') {
if (isHorizontal) axis = 'y'; else axis = 'x';
} else if (face === 'right' || face === 'left') {
if (isHorizontal) axis = 'y'; else axis = 'z';
} else if (face === 'up' || face === 'down') {
if (isHorizontal) axis = 'y';
else axis = 'x';
}
if (!axis) return
// Determine layer index
let index = 0
if (axis === 'x') index = cubie.x
if (axis === 'y') index = cubie.y
if (axis === 'z') index = cubie.z
activeLayer.value = { axis, index }
// Determine Sign (Visual mapping)
const delta = isHorizontal ? dx : dy
const baseSign = isHorizontal ? 1 : -1
const newRotation = delta * baseSign * 0.5
// Calculate velocity (deg/ms)
let dragVector = null
if (activeLayer.value) {
axis = activeLayer.value.axis
index = activeLayer.value.index
dragVector = activeLayer.value.dragVector
} else {
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return
const face = selectedFaceNormal.value
// Use dynamic mapping from debug settings if available, else fallback to constant
// But better to make ROTATION_MAPPING computed or access directly
const mapping = getRotationMapping(face)
if (!mapping) return
// Create basis vectors for the two possible tangent axes
const vectors = mapping.map(m => {
const v = { x: 0, y: 0, z: 0 }
v[m.axis] = 1
return { ...m, vector: v }
})
// Project them to screen space
const projected = vectors.map(v => {
const p = projectVector(v.vector)
const len = Math.sqrt(p.x * p.x + p.y * p.y)
return { ...v, px: p.x, py: p.y, len }
})
const mouseLen = Math.sqrt(dx * dx + dy * dy)
if (mouseLen === 0) return
const ndx = dx / mouseLen
const ndy = dy / mouseLen
let bestMatch = null
let maxDot = -1
projected.forEach(p => {
if (p.len < 0.1) return
const npx = p.px / p.len
const npy = p.py / p.len
const dot = Math.abs(ndx * npx + ndy * npy)
if (dot > maxDot) {
maxDot = dot
bestMatch = p
}
})
if (!bestMatch) return
axis = bestMatch.rotAxis
if (axis === 'x') index = cubie.x
if (axis === 'y') index = cubie.y
if (axis === 'z') index = cubie.z
dragVector = { x: bestMatch.px, y: bestMatch.py, sign: bestMatch.sign }
activeLayer.value = { axis, index, dragVector }
addLog('layer-select', { axis, index, vector: dragVector, face: selectedFaceNormal.value })
}
const { x: vx, y: vy, sign } = activeLayer.value.dragVector
const vLen = Math.sqrt(vx * vx + vy * vy)
if (vLen === 0) return
const nvx = vx / vLen
const nvy = vy / vLen
const moveAmount = dx * nvx + dy * nvy
const newRotation = moveAmount * sign * 0.5
if (dt > 0) {
const dRot = newRotation - layerRotation.value
// Simple low-pass filter for smoothing
velocity.value = 0.6 * velocity.value + 0.4 * (dRot / dt)
}
layerRotation.value = newRotation
}
@@ -322,36 +264,29 @@ const onMouseUp = async () => {
if (!isDragging.value) return
isDragging.value = false
document.body.style.cursor = ''
if (dragMode.value === 'layer' && activeLayer.value) {
isSnapping.value = true
// Inertia calculation
// Project final position based on velocity
// 200ms projection is reasonable for "throw" feel
const projection = velocity.value * 200
const projectedRot = layerRotation.value + projection
// Snap to nearest 90 degrees
const steps = Math.round(projectedRot / 90)
const targetRot = steps * 90
// Animation Loop
const startRot = layerRotation.value
const startTime = performance.now()
const duration = 300 // ms
// Ease out cubic function
const duration = 300
const easeOut = (t) => 1 - Math.pow(1 - t, 3)
return new Promise(resolve => {
const animate = (time) => {
const elapsed = time - startTime
const progress = Math.min(elapsed / duration, 1)
const ease = easeOut(progress)
layerRotation.value = startRot + (targetRot - startRot) * ease
if (progress < 1) {
rafId.value = requestAnimationFrame(animate)
} else {
@@ -368,86 +303,28 @@ const onMouseUp = async () => {
const finishRotation = (steps) => {
if (steps !== 0) {
// Update logical state
const { axis, index } = activeLayer.value
let layerName = null
if (axis === 'x') {
if (index === -1) layerName = 'left'
if (index === 1) layerName = 'right'
} else if (axis === 'y') {
if (index === 1) layerName = 'top'
if (index === -1) layerName = 'bottom'
} else if (axis === 'z') {
if (index === 1) layerName = 'front'
if (index === -1) layerName = 'back'
}
if (layerName) {
// Apply rotation to logical cube
let direction = steps > 0 ? 1 : -1
// Invert direction for specific layers where Visual and Logical rotations are opposite
if (layerName === 'top' || layerName === 'back' || layerName === 'right') {
direction = -direction
}
const count = Math.abs(steps)
// 1. Update Logical State (Colors)
for (let i = 0; i < count; i++) {
rotateLayer(layerName, direction)
}
// 2. Update Visual State (Cubies Position)
// We must rotate the (x,y,z) coordinates of the cubies that were in the active layer.
const visualSteps = steps // + means +90deg along axis
// Apply N times
const rotations = Math.abs(visualSteps)
const sign = Math.sign(visualSteps)
for (let r = 0; r < rotations; r++) {
cubies.value.forEach(cubie => {
// Check if cubie is in the rotating layer
let inLayer = false
if (axis === 'x' && cubie.x === index) inLayer = true
if (axis === 'y' && cubie.y === index) inLayer = true
if (axis === 'z' && cubie.z === index) inLayer = true
if (inLayer) {
const { x, y, z } = cubie
let nx = x, ny = y, nz = z
if (axis === 'x') {
if (sign > 0) { ny = -z; nz = y; } // (x, -z, y)
else { ny = z; nz = -y; } // (x, z, -y)
} else if (axis === 'y') {
if (sign > 0) { nx = z; nz = -x; } // (z, y, -x)
else { nx = -z; nz = x; } // (-z, y, x)
} else if (axis === 'z') {
if (sign > 0) { nx = -y; ny = x; } // (-y, x, z)
else { nx = y; ny = -x; } // (y, -x, z)
}
cubie.x = nx
cubie.y = ny
cubie.z = nz
}
})
}
// Calculate logical direction
// We found that Visual Rotation direction is inverted relative to Logical Rotation direction
// for all axes due to coordinate system differences (Y-down vs Y-up).
// Visual Positive -> Logical Negative.
const direction = steps > 0 ? -1 : 1
const count = Math.abs(steps)
for (let i = 0; i < count; i++) {
rotateLayer(axis, index, direction)
}
addLog('rotation-finish', { axis, index, direction, steps, count })
}
activeLayer.value = null
layerRotation.value = 0
isSnapping.value = false
velocity.value = 0
}
// Lifecycle
onMounted(() => {
initCubies()
initCube()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
@@ -459,51 +336,29 @@ onUnmounted(() => {
if (rafId.value) cancelAnimationFrame(rafId.value)
})
// Styles
const cubeStyle = computed(() => ({
transform: `rotateX(${rx.value}deg) rotateY(${ry.value}deg) rotateZ(${rz.value}deg)`
}))
const getCubieStyle = (cubie) => {
// Base position
// scale 300px total. 100px per cubie.
// x,y,z in -1..1.
// translate(x*100, -y*100, z*100).
// Y is inverted in CSS (down is positive)?
// Usually in 3D CSS:
// X right, Y down, Z towards viewer.
// My Grid: Y=1 is Top.
// So Y=1 -> translateY(-100px).
const tx = cubie.x * 100
const ty = cubie.y * -100
const tz = cubie.z * 100
let transform = `translate3d(${tx}px, ${ty}px, ${tz}px)`
// Apply rotation if active layer
if (activeLayer.value) {
const { axis, index } = activeLayer.value
let match = false
if (axis === 'x' && cubie.x === index) match = true
if (axis === 'y' && cubie.y === index) match = true
if (axis === 'z' && cubie.z === index) match = true
if (match) {
// Rotation origin is center of cube (0,0,0).
// But we are translating the cubie.
// To rotate around global axis, we should rotate THEN translate?
// No, the Group rotates.
// But here we rotate individual cubies.
// A cubie at (100,0,0) rotating around Y axis:
// Needs to move in arc.
// `rotateY(angle) translate(...)` -> Rotates axis then moves.
// Yes. `rotateY` first puts it on the rotated axis.
// So `rotate` then `translate`.
transform = `rotate${axis.toUpperCase()}(${layerRotation.value}deg) ${transform}`
}
}
return { transform }
}
</script>
@@ -512,12 +367,9 @@ const getCubieStyle = (cubie) => {
<div class="scene" @mousedown="onMouseDown">
<div class="container">
<div class="cube-group" :style="cubeStyle">
<div v-for="cubie in cubies" :key="cubie.id" class="cubie" :style="getCubieStyle(cubie)">
<!-- Render 6 faces for each cubie -->
<!-- Only render if color is not black? Optimization. -->
<div v-for="(color, face) in getCubieFaces(cubie)" :key="face"
<div v-for="(color, face) in cubie.faces" :key="face"
class="sticker-face"
:class="face"
:data-cubie-id="cubie.id"
@@ -525,7 +377,6 @@ const getCubieStyle = (cubie) => {
:style="{ backgroundColor: color }">
<div class="sticker-border"></div>
</div>
</div>
</div>
@@ -546,7 +397,7 @@ const getCubieStyle = (cubie) => {
width: 300px;
height: 300px;
perspective: 900px;
pointer-events: auto;
pointer-events: auto;
}
.cube-group {
@@ -576,8 +427,6 @@ const getCubieStyle = (cubie) => {
justify-content: center;
align-items: center;
backface-visibility: hidden; /* Optimization? Or we want to see inside? */
/* We want solid cubies. So we need backfaces or 6 faces. */
/* We are rendering 6 faces. */
}
.sticker-border {
@@ -586,9 +435,6 @@ const getCubieStyle = (cubie) => {
border: 2px solid rgba(0,0,0,0.5);
border-radius: 8px; /* Rounded sticker */
background: inherit; /* Sticker color */
/* The face bg is the plastic color (black usually). */
/* Here we set face bg to color directly. */
/* Let's adjust: face bg = black. sticker-border bg = color. */
}
/* Face transforms relative to Cubie Center */
@@ -600,4 +446,3 @@ const getCubieStyle = (cubie) => {
.sticker-face.down { transform: rotateX(-90deg) translateZ(50px); }
</style>