From 11e8013b6a5499474282eb52761cb45491d83eab Mon Sep 17 00:00:00 2001 From: Samuel Prevost Date: Tue, 14 Apr 2026 23:19:34 +0200 Subject: [PATCH] feat(cache): persist datums per file hash and user settings - Add file-hash.ts: SHA-256 hash of uploaded files via Web Crypto API - Add datum-cache.ts: localStorage save/load/clear for datums by hash - Add settings-cache.ts: persist scalePxPerMm and includeScaleBar - Restore datums from cache on re-upload of same file - Discreet "Clear cache" button on upload page - Store fileHash and cacheRestoreMessage in Pinia store - Auto-save datums on every change via deep watcher - Track maxStepReached for clickable step navigation Co-Authored-By: Claude --- src/components/ImageUpload.vue | 258 ++++++++++++++++++++++++++++++--- src/lib/datum-cache.ts | 48 ++++++ src/lib/file-hash.ts | 6 + src/lib/settings-cache.ts | 24 +++ src/stores/app.ts | 24 ++- 5 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 src/lib/datum-cache.ts create mode 100644 src/lib/file-hash.ts create mode 100644 src/lib/settings-cache.ts diff --git a/src/components/ImageUpload.vue b/src/components/ImageUpload.vue index 94cdf6e..11b1426 100644 --- a/src/components/ImageUpload.vue +++ b/src/components/ImageUpload.vue @@ -1,8 +1,10 @@ @@ -132,7 +166,193 @@ function onFileSelect(e: Event) { > {{ error }}

- - + + +
+ +
+ +
+

Example

+
+
+ Before: angled photograph of a Pioneer CDJ-1000MK3 top case +

Before — angled shot

+
+
+ After: perspective-corrected front-facing view +

After — corrected perspective

+
+
+
+ +
+

+ Correct perspective distortion in photographs using + known reference dimensions. Useful when you need to + use a photo as a scale reference for design work, + measure objects from photographs, or recover accurate + geometry from angled shots. +

+
+ +
+

How it works

+

+ Place an object with known dimensions (a ruler, credit card, or A4 sheet) next to the subject + you want to photograph. Take the picture from any angle. Skwik uses the reference object to + compute a perspective transform and produce a corrected, front-facing image with accurate + proportions. +

+

+ This is especially handy for reverse-engineering enclosure cutouts, measuring parts you + can't easily reach, or getting a dimensionally accurate top-down view without a tripod. +

+ +

Tips for best results

+
    +
  • + Use a large, rigid rectangle with precise dimensions. + An A4 magazine cover works well. Plain paper is acceptable but can bend or curl, + which degrades accuracy. +
  • +
  • + Lay everything on a flat surface. + Both the subject and the reference object must sit on the same plane. +
  • +
  • + Shoot from as high up as possible and zoom in. + Stand on a chair or stool and hold your phone at arm's length above the scene. + Use optical zoom (2× or more) to narrow the field of view — this compresses + perspective closer to an orthogonal projection and makes the correction more accurate. +
  • +
+ + +
+ + + + + + + + ref + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wide angle + (more distortion) + zoomed in + + + + + + + higher is better + +
+ +
+ diff --git a/src/lib/datum-cache.ts b/src/lib/datum-cache.ts new file mode 100644 index 0000000..2694604 --- /dev/null +++ b/src/lib/datum-cache.ts @@ -0,0 +1,48 @@ +import type { Datum } from "@/types" + +const KEY_PREFIX = "skwik-datums-" + +export function saveDatums(hash: string, datums: Datum[]): void { + try { + localStorage.setItem( + KEY_PREFIX + hash, + JSON.stringify(datums), + ) + } catch { + // localStorage full or unavailable — silently ignore + } +} + +export function loadDatums(hash: string): Datum[] | null { + try { + const raw = localStorage.getItem(KEY_PREFIX + hash) + if (!raw) return null + return JSON.parse(raw) as Datum[] + } catch { + return null + } +} + +export function clearCache(): void { + const toRemove: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key?.startsWith(KEY_PREFIX)) { + toRemove.push(key) + } + } + for (const key of toRemove) { + localStorage.removeItem(key) + } +} + +export function getCacheSize(): number { + let count = 0 + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key?.startsWith(KEY_PREFIX)) { + count++ + } + } + return count +} diff --git a/src/lib/file-hash.ts b/src/lib/file-hash.ts new file mode 100644 index 0000000..43178e9 --- /dev/null +++ b/src/lib/file-hash.ts @@ -0,0 +1,6 @@ +export async function hashFile(file: File): Promise { + const buffer = await file.arrayBuffer() + const hashBuffer = await crypto.subtle.digest("SHA-256", buffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") +} diff --git a/src/lib/settings-cache.ts b/src/lib/settings-cache.ts new file mode 100644 index 0000000..403651d --- /dev/null +++ b/src/lib/settings-cache.ts @@ -0,0 +1,24 @@ +export interface SkwikSettings { + scalePxPerMm: number + includeScaleBar: boolean +} + +const KEY = "skwik-settings" + +export function saveSettings(settings: SkwikSettings): void { + try { + localStorage.setItem(KEY, JSON.stringify(settings)) + } catch { + // localStorage full or unavailable — silently ignore + } +} + +export function loadSettings(): SkwikSettings | null { + try { + const raw = localStorage.getItem(KEY) + if (!raw) return null + return JSON.parse(raw) as SkwikSettings + } catch { + return null + } +} diff --git a/src/stores/app.ts b/src/stores/app.ts index 82872c2..269f2d3 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -2,9 +2,13 @@ import { defineStore } from "pinia" import { ref, computed } from "vue" import type { AppStep, Datum, DeskewResult, ExifData } from "@/types" import { DEFAULT_SCALE_PX_PER_MM } from "@/types" +import { loadSettings } from "@/lib/settings-cache" export const useAppStore = defineStore("app", () => { + const cached = loadSettings() + const currentStep = ref(1) + const maxStepReached = ref(1) const originalFile = ref(null) const loadedImage = ref(null) const exifData = ref({}) @@ -13,7 +17,11 @@ export const useAppStore = defineStore("app", () => { const isProcessing = ref(false) const processingStatus = ref("") const selectedDatumId = ref(null) - const scalePxPerMm = ref(DEFAULT_SCALE_PX_PER_MM) + const scalePxPerMm = ref( + cached?.scalePxPerMm ?? DEFAULT_SCALE_PX_PER_MM, + ) + const fileHash = ref(null) + const cacheRestoreMessage = ref("") const canProceedToStep2 = computed(() => loadedImage.value !== null) const canProceedToStep3 = computed(() => canProceedToStep2.value) @@ -36,6 +44,9 @@ export const useAppStore = defineStore("app", () => { function goToStep(step: AppStep) { currentStep.value = step + if (step > maxStepReached.value) { + maxStepReached.value = step + } } function addDatum(datum: Datum) { @@ -65,8 +76,13 @@ export const useAppStore = defineStore("app", () => { deskewResult.value = result } + function setFileHash(hash: string) { + fileHash.value = hash + } + function reset() { currentStep.value = 1 + maxStepReached.value = 1 originalFile.value = null loadedImage.value = null exifData.value = {} @@ -76,10 +92,13 @@ export const useAppStore = defineStore("app", () => { processingStatus.value = "" selectedDatumId.value = null scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM + fileHash.value = null + cacheRestoreMessage.value = "" } return { currentStep, + maxStepReached, originalFile, loadedImage, exifData, @@ -89,6 +108,8 @@ export const useAppStore = defineStore("app", () => { processingStatus, selectedDatumId, scalePxPerMm, + fileHash, + cacheRestoreMessage, canProceedToStep2, canProceedToStep3, canProceedToStep4, @@ -99,6 +120,7 @@ export const useAppStore = defineStore("app", () => { updateDatum, removeDatum, setResult, + setFileHash, reset, } })