AR-Drawing / index.html
DarqueDante's picture
Update index.html
bac966b verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AR Drawing Overlay</title>
<style>
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background-color: #f9f9f9;
text-align: center;
overflow: hidden; /* Prevent scrollbars */
}
#canvas {
width: 100%;
height: 85vh;
max-width: 600px;
border: 1px solid #ccc;
touch-action: none;
display: block;
margin: 0 auto;
}
#controls {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(255, 255, 255, 0.9);
padding: 5px;
box-sizing: border-box;
display: none; /* Hidden by default */
max-height: 25vh;
overflow-y: auto;
}
button {
font-size: 1em;
padding: 5px;
margin: 2px;
min-width: 40px;
min-height: 40px;
border-radius: 5px;
border: 1px solid #aaa;
background-color: #f0f0f0;
}
button.active {
background-color: #a0c4ff; /* Highlight active toggles */
border-color: #6a9eff;
}
#toggleControls {
position: fixed;
bottom: 10px;
right: 10px;
font-size: 1.5em;
background: #fff;
border: 1px solid #ccc;
border-radius: 50%;
width: 40px;
height: 40px;
z-index: 100;
}
#errorMessage {
color: red;
font-size: 0.8rem;
margin: 5px 0;
}
#tutorialModal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 101;
}
#tutorialContent {
background: white;
margin: 10% auto;
padding: 10px;
width: 90%;
max-width: 350px;
text-align: left;
font-size: 0.8rem;
border-radius: 8px;
}
.control-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 5px;
margin-bottom: 5px;
}
.control-row label, .control-row select, .control-row input {
font-size: 0.8em;
}
#toastNotification {
position: fixed;
bottom: 70px; /* Above controls button */
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 20px;
border-radius: 20px;
z-index: 102;
opacity: 0;
transition: opacity 0.5s ease-in-out;
pointer-events: none; /* Don't block clicks */
}
</style>
</head>
<body>
<h2>AR Drawing Overlay</h2>
<p style="font-size: 0.9rem;">Trace pictures! Tap ≡ for controls.</p>
<div id="errorMessage"></div>
<video id="video" autoplay playsinline style="display: none;"></video>
<canvas id="canvas"></canvas>
<button id="toggleControls" aria-label="Toggle controls panel"></button>
<div id="controls">
<div class="control-row">
<label for="cameraSelect">Pick Camera:</label>
<select id="cameraSelect" onchange="switchCamera()">
<option value="">Select a camera</option>
</select>
</div>
<div class="control-row">
<label for="overlayImage">Pick Image:</label>
<input type="file" id="overlayImage" accept="image/png, image/jpeg, image/bmp, image/gif" />
</div>
<div class="control-row">
<label for="transparency">Transparency:</label>
<input type="range" id="transparency" min="0" max="1" step="0.01" value="0.5" style="width: 80px;" />
<span id="transparencyValue">0.5</span>
</div>
<div class="control-row">
<button onclick="moveLeft()" aria-label="Move overlay left"></button>
<button onclick="moveUp()" aria-label="Move overlay up"></button>
<button onclick="moveDown()" aria-label="Move overlay down"></button>
<button onclick="moveRight()" aria-label="Move overlay right"></button>
<button onclick="zoomIn()" aria-label="Zoom in overlay">+</button>
<button onclick="zoomOut()" aria-label="Zoom out overlay">-</button>
</div>
<div class="control-row">
<button onclick="rotateLeft()" aria-label="Rotate left"></button>
<button onclick="rotateRight()" aria-label="Rotate right"></button>
<button onclick="flipHorizontal()" aria-label="Flip horizontally">Flip H</button>
<button onclick="flipVertical()" aria-label="Flip vertically">Flip V</button>
<button onclick="reset()" aria-label="Reset overlay">Reset</button>
</div>
<div class="control-row">
<button id="toggleCameraBtn" onclick="toggleCamera()" aria-label="Pause camera">Pause</button>
<button id="edgeDetectBtn" onclick="toggleEdgeDetection()" aria-label="Toggle edge detection">Edges</button>
<button id="lockBtn" onclick="toggleLock()" aria-label="Lock overlay position">🔒</button>
<button onclick="takeSnapshot()" aria-label="Take snapshot">Snap</button>
</div>
<div class="control-row">
<button onclick="saveConfig()" aria-label="Save configuration">Save</button>
<button onclick="loadConfig()" aria-label="Load configuration">Load</button>
<button id="tutorialBtn" aria-label="Show tutorial">Help</button>
</div>
</div>
<div id="toastNotification"></div>
<!-- Tutorial Modal -->
<div id="tutorialModal">
<div id="tutorialContent">
<h3>How to Use the AR Drawing Overlay</h3>
<p>Hi! This helps you trace pictures:</p>
<ol>
<li><strong>Pick a Camera:</strong> Tap ≡, then choose a camera.</li>
<li><strong>Pick a Picture:</strong> Tap "Pick Image" to choose a picture.</li>
<li><strong>Adjust Overlay:</strong>
<ul>
<li>Use the slider for <strong>Transparency</strong>.</li>
<li>Use ← ↑ ↓ → to <strong>move</strong> it. Drag with one finger.</li>
<li>Use + and - to <strong>zoom</strong>. Pinch with two fingers.</li>
<li>Use ↺ and ↻ to <strong>rotate</strong>. Twist with two fingers.</li>
<li>Use <strong>Flip H/V</strong> to mirror the image.</li>
</ul>
</li>
<li><strong>Lock It:</strong> Tap 🔒 to lock the overlay in place.</li>
<li><strong>Trace Easier:</strong> Tap "Edges" to show only the outlines of your image.</li>
<li><strong>Pause/Snap:</strong> Tap "Pause" to freeze the camera, and "Snap" to save a picture.</li>
<li><strong>Save/Load:</strong> Save your setup for later with "Save" and "Load".</li>
</ol>
<p>Point your camera at paper and draw!</p>
<button onclick="closeTutorial()">Close</button>
</div>
</div>
<script>
// --- Global State Variables ---
let S_user = 1;
let offsetXFraction = 0;
let offsetYFraction = 0;
let rotationAngle = 0; // In radians
let isFlippedHorizontal = false;
let isFlippedVertical = false;
let isLocked = false;
let edgeDetectionEnabled = false;
let cameraPaused = false;
let currentStream = null;
let canvasRect;
let selectedCameraId = null;
// Image objects
let originalOverlayImg = new Image();
let displayOverlayImg = new Image(); // This is what gets drawn
let overlayLoaded = false;
// --- DOM Elements ---
const canvas = document.getElementById("canvas");
const video = document.getElementById("video");
const errorMessage = document.getElementById("errorMessage");
const cameraSelect = document.getElementById("cameraSelect");
const transparencySlider = document.getElementById("transparency");
const transparencyValueDisplay = document.getElementById("transparencyValue");
const lockBtn = document.getElementById("lockBtn");
const edgeDetectBtn = document.getElementById("edgeDetectBtn");
// --- Camera Functions ---
async function populateCameraOptions() {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
errorMessage.textContent = "Camera selection not supported.";
return;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === "videoinput");
cameraSelect.innerHTML = "<option value=''>Select a camera</option>";
videoDevices.forEach((device, index) => {
const option = document.createElement("option");
option.value = device.deviceId;
option.text = device.label || `Camera ${index + 1}`;
cameraSelect.appendChild(option);
});
if (videoDevices.length > 0) {
cameraSelect.value = videoDevices[0].deviceId;
selectedCameraId = videoDevices[0].deviceId;
}
} catch (error) {
console.error("Error listing cameras:", error);
errorMessage.textContent = "Error finding cameras: " + error.message;
}
}
function startCamera() {
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop());
currentStream = null;
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
errorMessage.textContent = "Your browser doesn’t support the camera.";
return;
}
const constraints = selectedCameraId
? { video: { deviceId: { exact: selectedCameraId } } }
: { video: { facingMode: "environment" } };
navigator.mediaDevices
.getUserMedia(constraints)
.then((stream) => {
currentStream = stream;
video.srcObject = stream;
video.onloadedmetadata = () => video.play();
errorMessage.textContent = "";
})
.catch((error) => {
console.error("Camera error:", error);
errorMessage.textContent = "Camera error: " + error.message;
});
}
function switchCamera() {
selectedCameraId = cameraSelect.value;
if (selectedCameraId && !cameraPaused) startCamera();
}
function toggleCamera() {
const toggleBtn = document.getElementById("toggleCameraBtn");
cameraPaused = !cameraPaused;
if (cameraPaused) {
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop());
currentStream = null;
}
toggleBtn.textContent = "Play";
} else {
startCamera();
toggleBtn.textContent = "Pause";
}
}
// --- Overlay Transformation Controls ---
function moveLeft() { if (isLocked) return; offsetXFraction -= 0.05; }
function moveRight() { if (isLocked) return; offsetXFraction += 0.05; }
function moveUp() { if (isLocked) return; offsetYFraction -= 0.05; }
function moveDown() { if (isLocked) return; offsetYFraction += 0.05; }
function zoomIn() { if (isLocked) return; S_user *= 1.1; }
function zoomOut() { if (isLocked) return; S_user /= 1.1; }
function rotateLeft() { if (isLocked) return; rotationAngle -= Math.PI / 18; } // 10 degrees
function rotateRight() { if (isLocked) return; rotationAngle += Math.PI / 18; }
function flipHorizontal() { if (isLocked) return; isFlippedHorizontal = !isFlippedHorizontal; }
function flipVertical() { if (isLocked) return; isFlippedVertical = !isFlippedVertical; }
function reset() {
if (isLocked) return;
S_user = 1;
offsetXFraction = 0;
offsetYFraction = 0;
rotationAngle = 0;
isFlippedHorizontal = false;
isFlippedVertical = false;
if (edgeDetectionEnabled) toggleEdgeDetection(); // Turn off edge detection
showToast("Overlay Reset");
}
function toggleLock() {
isLocked = !isLocked;
lockBtn.textContent = isLocked ? "🔓" : "🔒";
lockBtn.classList.toggle('active', isLocked);
showToast(isLocked ? "Overlay Locked" : "Overlay Unlocked");
}
// --- Image Handling & Processing ---
document.getElementById("overlayImage").addEventListener("change", (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
originalOverlayImg.src = event.target.result;
};
reader.readAsDataURL(file);
}
});
originalOverlayImg.onload = () => {
overlayLoaded = true;
if (edgeDetectionEnabled) {
applyEdgeDetection();
} else {
displayOverlayImg.src = originalOverlayImg.src;
}
errorMessage.textContent = "";
showToast("Image loaded");
};
function toggleEdgeDetection() {
if (!overlayLoaded) {
showToast("Please load an image first.");
return;
}
edgeDetectionEnabled = !edgeDetectionEnabled;
edgeDetectBtn.classList.toggle('active', edgeDetectionEnabled);
if (edgeDetectionEnabled) {
showToast("Applying edge detection...");
setTimeout(applyEdgeDetection, 10); // Use timeout to show toast first
} else {
displayOverlayImg.src = originalOverlayImg.src;
showToast("Edge detection off.");
}
}
function applyEdgeDetection() {
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d', { willReadFrequently: true });
offscreenCanvas.width = originalOverlayImg.naturalWidth;
offscreenCanvas.height = originalOverlayImg.naturalHeight;
// 1. Grayscale
offscreenCtx.drawImage(originalOverlayImg, 0, 0);
const imageData = offscreenCtx.getImageData(0, 0, offscreenCanvas.width, offscreenCanvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = data[i + 1] = data[i + 2] = avg;
}
// 2. Sobel Operator for edge detection
const sobelData = sobel(imageData);
const sobelImageData = offscreenCtx.createImageData(offscreenCanvas.width, offscreenCanvas.height);
for (let i = 0; i < sobelData.data.length; i += 4) {
const magnitude = sobelData.data[i];
sobelImageData.data[i] = magnitude;
sobelImageData.data[i + 1] = magnitude;
sobelImageData.data[i + 2] = magnitude;
sobelImageData.data[i + 3] = 255;
}
offscreenCtx.putImageData(sobelImageData, 0, 0);
displayOverlayImg.src = offscreenCanvas.toDataURL();
showToast("Edge detection applied.");
}
// Sobel operator function (simplified)
function sobel(imageData) {
const width = imageData.width;
const height = imageData.height;
const grayscaleData = new Uint8ClampedArray(width * height);
for (let i = 0; i < imageData.data.length; i += 4) {
grayscaleData[i / 4] = imageData.data[i];
}
const kernelX = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]];
const kernelY = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]];
const sobelData = new Uint8ClampedArray(width * height * 4);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let pixelX = 0;
let pixelY = 0;
for (let j = -1; j <= 1; j++) {
for (let i = -1; i <= 1; i++) {
const pixel = grayscaleData[(y + j) * width + (x + i)];
pixelX += pixel * kernelX[j + 1][i + 1];
pixelY += pixel * kernelY[j + 1][i + 1];
}
}
const magnitude = Math.sqrt(pixelX * pixelX + pixelY * pixelY) > 128 ? 0 : 255;
const index = (y * width + x) * 4;
sobelData[index] = sobelData[index + 1] = sobelData[index + 2] = magnitude;
sobelData[index + 3] = 255;
}
}
return { data: sobelData, width: width, height: height };
}
// --- Touch Controls ---
let isDragging = false, isPinching = false, isRotating = false;
let touchStartX = 0, touchStartY = 0;
let startOffsetXFraction = 0, startOffsetYFraction = 0;
let initialPinchDistance = 0, initialScale = 1;
let initialTouchAngle = 0, startRotationAngle = 0;
function getDistance(t1, t2) { return Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); }
function getAngle(t1, t2) { return Math.atan2(t2.clientY - t1.clientY, t2.clientX - t1.clientX); }
canvas.addEventListener("touchstart", (e) => {
if (isLocked || !overlayLoaded) return;
canvasRect = canvas.getBoundingClientRect();
if (e.touches.length === 1) {
isDragging = true;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
startOffsetXFraction = offsetXFraction;
startOffsetYFraction = offsetYFraction;
} else if (e.touches.length === 2) {
isPinching = true;
isRotating = true;
initialPinchDistance = getDistance(e.touches[0], e.touches[1]);
initialScale = S_user;
initialTouchAngle = getAngle(e.touches[0], e.touches[1]);
startRotationAngle = rotationAngle;
}
}, false);
canvas.addEventListener("touchmove", (e) => {
e.preventDefault();
if (isLocked || !overlayLoaded) return;
if (isDragging && e.touches.length === 1) {
offsetXFraction = startOffsetXFraction + (e.touches[0].clientX - touchStartX) / canvasRect.width;
offsetYFraction = startOffsetYFraction + (e.touches[0].clientY - touchStartY) / canvasRect.height;
} else if ((isPinching || isRotating) && e.touches.length === 2) {
// Pinch to Zoom
const currentDistance = getDistance(e.touches[0], e.touches[1]);
S_user = initialScale * (currentDistance / initialPinchDistance);
// Two-finger rotate
const currentAngle = getAngle(e.touches[0], e.touches[1]);
rotationAngle = startRotationAngle + (currentAngle - initialTouchAngle);
}
}, { passive: false });
canvas.addEventListener("touchend", (e) => {
if (e.touches.length < 2) { isPinching = false; isRotating = false; }
if (e.touches.length < 1) { isDragging = false; }
}, false);
// --- Main Draw Loop ---
function draw() {
const ctx = canvas.getContext("2d");
if (!cameraPaused && video.readyState === video.HAVE_ENOUGH_DATA) {
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
if (overlayLoaded && displayOverlayImg.complete) {
const S_fit = Math.min(canvas.width / displayOverlayImg.naturalWidth, canvas.height / displayOverlayImg.naturalHeight);
const S = S_fit * S_user;
const imgWidth = S * displayOverlayImg.naturalWidth;
const imgHeight = S * displayOverlayImg.naturalHeight;
const dx = (canvas.width - imgWidth) / 2 + offsetXFraction * canvas.width;
const dy = (canvas.height - imgHeight) / 2 + offsetYFraction * canvas.height;
ctx.globalAlpha = 1.0 - parseFloat(transparencySlider.value);
// Apply transformations (rotation, flip)
ctx.save();
ctx.translate(dx + imgWidth / 2, dy + imgHeight / 2); // Move origin to image center
ctx.rotate(rotationAngle);
ctx.scale(isFlippedHorizontal ? -1 : 1, isFlippedVertical ? -1 : 1);
ctx.drawImage(displayOverlayImg, -imgWidth / 2, -imgHeight / 2, imgWidth, imgHeight); // Draw centered on new origin
ctx.restore(); // Restore context to original state
ctx.globalAlpha = 1.0;
}
}
requestAnimationFrame(draw);
}
// --- Utility & UI Functions ---
function takeSnapshot() {
const link = document.createElement("a");
link.href = canvas.toDataURL("image/png");
link.download = "ar_drawing_snapshot.png";
link.click();
}
function showToast(message) {
const toast = document.getElementById("toastNotification");
toast.textContent = message;
toast.style.opacity = 1;
setTimeout(() => {
toast.style.opacity = 0;
}, 2500);
}
function saveConfig() {
const transparency = transparencySlider.value;
const config = { S_user, offsetXFraction, offsetYFraction, transparency, rotationAngle, isFlippedHorizontal, isFlippedVertical };
localStorage.setItem("arOverlayConfig", JSON.stringify(config));
showToast("Configuration Saved!");
}
function loadConfig() {
const config = localStorage.getItem("arOverlayConfig");
if (config) {
const parsed = JSON.parse(config);
S_user = parsed.S_user || 1;
offsetXFraction = parsed.offsetXFraction || 0;
offsetYFraction = parsed.offsetYFraction || 0;
rotationAngle = parsed.rotationAngle || 0;
isFlippedHorizontal = parsed.isFlippedHorizontal || false;
isFlippedVertical = parsed.isFlippedVertical || false;
transparencySlider.value = parsed.transparency || 0.5;
transparencyValueDisplay.textContent = parsed.transparency || 0.5;
showToast("Configuration Loaded!");
} else {
showToast("No saved configuration found.");
}
}
document.getElementById("toggleControls").addEventListener("click", function () {
const controls = document.getElementById("controls");
controls.style.display = window.getComputedStyle(controls).display === "none" ? "block" : "none";
});
document.getElementById("tutorialBtn").addEventListener("click", () => {
document.getElementById("tutorialModal").style.display = "block";
});
function closeTutorial() {
document.getElementById("tutorialModal").style.display = "none";
}
transparencySlider.addEventListener("input", () => {
transparencyValueDisplay.textContent = transparencySlider.value;
});
// --- Initialization ---
window.addEventListener("load", () => {
populateCameraOptions().then(() => startCamera());
draw();
});
</script>
</body>
</html>