feat(datums): broaden image upload, swap W/H, validate rect corners
- Accept any browser-supported image type (png, webp, gif, …) in addition to jpg and heic on the upload step. - Add a swap button between the width and height inputs on rectangle datums so users can flip dimensions with one click. - Block the Next action on the datum step when a rectangle has crossed corners (top below bottom or right left of left), and surface which datums need fixing in the tooltip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27b23a61d9
commit
a71c8c73ef
@ -3,6 +3,7 @@ import { ref, computed, watch } from "vue"
|
|||||||
import { useMediaQuery } from "@vueuse/core"
|
import { useMediaQuery } from "@vueuse/core"
|
||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
import { saveDatums } from "@/lib/datum-cache"
|
import { saveDatums } from "@/lib/datum-cache"
|
||||||
|
import { isRectCrossed } from "@/lib/datums"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@ -36,13 +37,34 @@ const incompleteDatums = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const crossedRects = computed(() =>
|
||||||
|
store.datums.filter(
|
||||||
|
(d) => d.type === "rectangle" && isRectCrossed(d),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const canGoNext = computed(
|
||||||
|
() => store.canProceedToStep4 && crossedRects.value.length === 0,
|
||||||
|
)
|
||||||
|
|
||||||
const nextTooltip = computed(() => {
|
const nextTooltip = computed(() => {
|
||||||
if (store.datums.length === 0) return "Add at least one datum"
|
if (store.datums.length === 0) return "Add at least one datum"
|
||||||
if (incompleteDatums.value.length === 0) return ""
|
if (incompleteDatums.value.length > 0) {
|
||||||
const names = incompleteDatums.value.map((d) => d.label)
|
const names = incompleteDatums.value.map((d) => d.label)
|
||||||
return `Missing dimensions: ${names.join(", ")}`
|
return `Missing dimensions: ${names.join(", ")}`
|
||||||
|
}
|
||||||
|
if (crossedRects.value.length > 0) {
|
||||||
|
const names = crossedRects.value.map((d) => d.label)
|
||||||
|
return `Crossed corners — fix corner order on: ${names.join(", ")}`
|
||||||
|
}
|
||||||
|
return ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (!canGoNext.value) return
|
||||||
|
store.goToStep(4)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.datums,
|
() => store.datums,
|
||||||
(datums) => {
|
(datums) => {
|
||||||
@ -74,8 +96,8 @@ watch(
|
|||||||
<span class="inline-flex">
|
<span class="inline-flex">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!store.canProceedToStep4"
|
:disabled="!canGoNext"
|
||||||
@click="store.goToStep(4)"
|
@click="handleNext"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -46,6 +46,13 @@ function updateField(datum: Datum, field: string, value: string | number) {
|
|||||||
store.updateDatum(datum.id, { [field]: value })
|
store.updateDatum(datum.id, { [field]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function swapRectDims(datum: RectDatum) {
|
||||||
|
store.updateDatum(datum.id, {
|
||||||
|
widthMm: datum.heightMm,
|
||||||
|
heightMm: datum.widthMm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function updateConfidence(datum: Datum, val: number[] | undefined) {
|
function updateConfidence(datum: Datum, val: number[] | undefined) {
|
||||||
if (!val) return
|
if (!val) return
|
||||||
const v = val[0]
|
const v = val[0]
|
||||||
@ -191,7 +198,7 @@ function formatDimensions(datum: Datum): string {
|
|||||||
<!-- Dimensions -->
|
<!-- Dimensions -->
|
||||||
<div
|
<div
|
||||||
v-if="datum.type === 'rectangle'"
|
v-if="datum.type === 'rectangle'"
|
||||||
class="grid grid-cols-2 gap-2"
|
class="grid grid-cols-[1fr_auto_1fr] items-end gap-2"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label class="text-xs">Width (mm)</Label>
|
<Label class="text-xs">Width (mm)</Label>
|
||||||
@ -209,6 +216,31 @@ function formatDimensions(datum: Datum): string {
|
|||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 text-muted-foreground"
|
||||||
|
title="Swap width and height"
|
||||||
|
aria-label="Swap width and height"
|
||||||
|
@click.stop="swapRectDims(datum as RectDatum)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="17 1 21 5 17 9" />
|
||||||
|
<path d="M3 11V9a2 2 0 0 1 2-2h16" />
|
||||||
|
<polyline points="7 23 3 19 7 15" />
|
||||||
|
<path d="M21 13v2a2 2 0 0 1-2 2H3" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<Label class="text-xs">Height (mm)</Label>
|
<Label class="text-xs">Height (mm)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@ -21,7 +21,7 @@ const error = ref("")
|
|||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const cacheCount = ref(0)
|
const cacheCount = ref(0)
|
||||||
|
|
||||||
const ACCEPTED = ".jpg,.jpeg,.heic,.heif"
|
const ACCEPTED = "image/*,.heic,.heif"
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
cacheCount.value = getCacheSize()
|
cacheCount.value = getCacheSize()
|
||||||
@ -94,8 +94,8 @@ function onFileSelect(e: Event) {
|
|||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle class="text-lg">Load Source Image</CardTitle>
|
<CardTitle class="text-lg">Load Source Image</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Drop a JPG or HEIC file, or click to browse. HEIC
|
Drop any image (JPG, PNG, WebP, HEIC, …), or click
|
||||||
is converted automatically.
|
to browse. HEIC is converted automatically.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -147,7 +147,7 @@ function onFileSelect(e: Event) {
|
|||||||
<p
|
<p
|
||||||
class="mt-1 font-mono text-xs text-muted-foreground/60"
|
class="mt-1 font-mono text-xs text-muted-foreground/60"
|
||||||
>
|
>
|
||||||
.jpg .jpeg .heic .heif
|
.jpg .jpeg .png .webp .gif .heic .heif
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,17 @@ export function createRectDatum(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rectangle corners are stored as [TL, TR, BR, BL]. A rectangle is
|
||||||
|
* "crossed" when a user has dragged corners past each other so the ordering
|
||||||
|
* no longer holds: top corners must sit above bottom corners (smaller y in
|
||||||
|
* image coordinates), and right corners must sit right of left corners.
|
||||||
|
*/
|
||||||
|
export function isRectCrossed(rect: RectDatum): boolean {
|
||||||
|
const [tl, tr, br, bl] = rect.corners
|
||||||
|
return tl.y >= bl.y || tr.y >= br.y || tl.x >= tr.x || bl.x >= br.x
|
||||||
|
}
|
||||||
|
|
||||||
export function createLineDatum(center: Point, index: number): LineDatum {
|
export function createLineDatum(center: Point, index: number): LineDatum {
|
||||||
const spread = 100
|
const spread = 100
|
||||||
return {
|
return {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user