Add MARD pattern image import
This commit is contained in:
+17
-1
@@ -52,7 +52,10 @@ button { color: inherit; }
|
||||
.upload-overlay { position: absolute; z-index: 3; right: 10px; bottom: 10px; padding: 7px 10px; border: 0; color: white; background: rgba(22, 75, 57, .9); cursor: pointer; font-size: 12px; }
|
||||
.file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 9px 0 24px; color: var(--muted); font-size: 11px; }
|
||||
.crop-select-button { width: 100%; margin-top: 0; padding: 10px; border: 1px solid var(--ink); background: var(--lime); cursor: pointer; font-size: 12px; font-weight: 700; }
|
||||
.reset-crop { width: 100%; margin: 7px 0 20px; padding: 7px; border: 1px solid var(--line); background: white; cursor: pointer; color: var(--muted); font-size: 11px; }
|
||||
.reset-crop { width: 100%; margin: 7px 0 0; padding: 7px; border: 1px solid var(--line); background: white; cursor: pointer; color: var(--muted); font-size: 11px; }
|
||||
.pattern-import-button { width: 100%; margin: 7px 0 20px; padding: 9px; border: 1px solid #b6532d; background: #fff5ec; color: #8f3f20; cursor: pointer; font-size: 12px; font-weight: 700; }
|
||||
.pattern-import-button:hover { background: #fee5d1; }
|
||||
.pattern-import-file { display: none; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 8px; }
|
||||
.field-row label, .field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field-row label span, .field > span { color: var(--muted); font-size: 12px; }
|
||||
@@ -150,6 +153,12 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.crop-mode-switch button { min-height: 34px; padding: 0 15px; border: 0; border-right: 1px solid var(--ink); background: transparent; cursor: pointer; font-size: 12px; }
|
||||
.crop-mode-switch button:last-child { border-right: 0; }
|
||||
.crop-mode-switch button.active { background: var(--green); color: white; font-weight: 700; }
|
||||
.pattern-import-dimensions { display: flex; align-items: flex-end; gap: 8px; }
|
||||
.pattern-import-dimensions label { display: grid; gap: 3px; color: var(--muted); font-size: 10px; }
|
||||
.pattern-import-dimensions input { width: 82px; height: 34px; padding: 0 8px; border: 1px solid var(--line); background: white; color: var(--ink); font-size: 12px; }
|
||||
.pattern-import-dimensions b { padding-bottom: 9px; color: var(--muted); font-weight: 400; }
|
||||
.pattern-import-empty-option { display: flex; align-items: center; gap: 6px; color: var(--ink); cursor: pointer; }
|
||||
.pattern-import-empty-option input { accent-color: var(--green); }
|
||||
.crop-size-readout { margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
.crop-dialog-scroll { min-height: 0; flex: 1 1 auto; overflow: auto; padding: 18px; display: grid; place-items: center; background-color: #e9e5db; background-image: radial-gradient(#c8c3b8 .7px, transparent .7px); background-size: 12px 12px; }
|
||||
.crop-dialog-stage { position: relative; width: max-content; max-width: 100%; line-height: 0; cursor: crosshair; touch-action: none; user-select: none; box-shadow: 0 0 0 1px var(--ink); }
|
||||
@@ -165,6 +174,7 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.crop-selection-box::before, .crop-selection-box::after { content: ""; position: absolute; background: rgba(255,255,255,.62); }
|
||||
.crop-selection-box::before { left: 33.333%; top: 0; bottom: 0; width: 1px; box-shadow: calc(33.333% * 1) 0 0 rgba(255,255,255,.62); }
|
||||
.crop-selection-box::after { left: 0; right: 0; top: 33.333%; height: 1px; box-shadow: 0 calc(33.333% * 1) 0 rgba(255,255,255,.62); }
|
||||
.pattern-import-grid-overlay { position: absolute; z-index: 3; inset: 0; display: block; pointer-events: none; background-image: linear-gradient(to right, rgba(255,102,0,.66) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,102,0,.66) 1px, transparent 1px); box-shadow: inset -1px -1px 0 rgba(255,102,0,.66); }
|
||||
.crop-resize-handle { position: absolute; z-index: 4; width: 15px; height: 15px; display: block; border: 2px solid white; border-radius: 50%; background: var(--green); box-shadow: 0 1px 4px rgba(0,0,0,.48); pointer-events: auto; }
|
||||
.handle-nw { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; }
|
||||
.handle-ne { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; }
|
||||
@@ -222,10 +232,16 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.crop-mode-bar { padding: 8px 12px; flex-wrap: wrap; gap: 7px 10px; }
|
||||
.crop-mode-switch { flex: 1; }
|
||||
.crop-mode-switch button { flex: 1; padding: 0 8px; }
|
||||
.pattern-import-dimensions { width: 100%; }
|
||||
.pattern-import-dimensions label { flex: 1; }
|
||||
.pattern-import-dimensions input { width: 100%; min-height: 42px; }
|
||||
.pattern-import-empty-option { width: 100%; min-height: 34px; }
|
||||
.crop-size-readout { width: 100%; margin-left: 0; }
|
||||
.crop-resize-handle { width: 28px; height: 28px; border-width: 3px; }
|
||||
.crop-edge-handle { display: none; }
|
||||
.crop-dialog-scroll { padding: 10px; }
|
||||
.crop-dialog-stage { width: 100%; max-width: 100%; }
|
||||
.crop-dialog-stage canvas { width: 100%; max-width: 100%; height: auto; }
|
||||
.crop-dialog-actions { padding: 10px 12px; flex-wrap: wrap; }
|
||||
.crop-dialog-actions span { width: 100%; order: -1; }
|
||||
.crop-dialog-actions button { flex: 1; padding: 0 10px; }
|
||||
|
||||
+358
-13
@@ -36,6 +36,7 @@ type CropRect = {
|
||||
};
|
||||
|
||||
type CropSelectionMode = "free" | "square";
|
||||
type CropDialogPurpose = "source" | "pattern-import";
|
||||
|
||||
type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
|
||||
|
||||
@@ -228,6 +229,231 @@ function nearestColor(lab: Oklab, palette: BeadColor[]) {
|
||||
return closest;
|
||||
}
|
||||
|
||||
type NormalizedGlyph = {
|
||||
pixels: Uint8Array;
|
||||
count: number;
|
||||
};
|
||||
|
||||
let mardCodeTemplates: Array<{ code: string; glyph: NormalizedGlyph }> | null = null;
|
||||
|
||||
function normalizeGlyph(mask: Uint8Array, width: number, height: number, targetWidth = 32, targetHeight = 16): NormalizedGlyph | null {
|
||||
let left = width;
|
||||
let right = -1;
|
||||
let top = height;
|
||||
let bottom = -1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (!mask[y * width + x]) continue;
|
||||
left = Math.min(left, x);
|
||||
right = Math.max(right, x);
|
||||
top = Math.min(top, y);
|
||||
bottom = Math.max(bottom, y);
|
||||
}
|
||||
}
|
||||
if (right < left || bottom < top) return null;
|
||||
const sourceWidth = right - left + 1;
|
||||
const sourceHeight = bottom - top + 1;
|
||||
const scale = Math.min((targetWidth - 2) / sourceWidth, (targetHeight - 2) / sourceHeight);
|
||||
const drawWidth = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const drawHeight = Math.max(1, Math.round(sourceHeight * scale));
|
||||
const offsetX = Math.floor((targetWidth - drawWidth) / 2);
|
||||
const offsetY = Math.floor((targetHeight - drawHeight) / 2);
|
||||
const pixels = new Uint8Array(targetWidth * targetHeight);
|
||||
let count = 0;
|
||||
for (let y = 0; y < drawHeight; y++) {
|
||||
for (let x = 0; x < drawWidth; x++) {
|
||||
const sourceX = left + Math.min(sourceWidth - 1, Math.floor(x / scale));
|
||||
const sourceY = top + Math.min(sourceHeight - 1, Math.floor(y / scale));
|
||||
if (!mask[sourceY * width + sourceX]) continue;
|
||||
pixels[(offsetY + y) * targetWidth + offsetX + x] = 1;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count >= 3 ? { pixels, count } : null;
|
||||
}
|
||||
|
||||
function buildMardCodeTemplates() {
|
||||
if (mardCodeTemplates) return mardCodeTemplates;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 96;
|
||||
canvas.height = 48;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||
mardCodeTemplates = PALETTE.map((color) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.font = "700 28px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(color.code, canvas.width / 2, canvas.height / 2);
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const mask = new Uint8Array(canvas.width * canvas.height);
|
||||
for (let index = 0; index < mask.length; index++) {
|
||||
const offset = index * 4;
|
||||
const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114;
|
||||
if (luminance < 150) mask[index] = 1;
|
||||
}
|
||||
return { code: color.code, glyph: normalizeGlyph(mask, canvas.width, canvas.height)! };
|
||||
});
|
||||
return mardCodeTemplates;
|
||||
}
|
||||
|
||||
function glyphSimilarity(first: NormalizedGlyph, second: NormalizedGlyph) {
|
||||
let intersection = 0;
|
||||
for (let index = 0; index < first.pixels.length; index++) {
|
||||
if (first.pixels[index] && second.pixels[index]) intersection += 1;
|
||||
}
|
||||
return (2 * intersection) / Math.max(1, first.count + second.count);
|
||||
}
|
||||
|
||||
function dominantCellBackground(data: Uint8ClampedArray, canvasWidth: number, startX: number, startY: number, cellSize: number) {
|
||||
const bins = new Map<number, { count: number; red: number; green: number; blue: number; alpha: number }>();
|
||||
const inset = Math.max(2, Math.floor(cellSize * 0.12));
|
||||
for (let y = inset; y < cellSize - inset; y++) {
|
||||
for (let x = inset; x < cellSize - inset; x++) {
|
||||
const nx = x / cellSize;
|
||||
const ny = y / cellSize;
|
||||
if (nx > 0.34 && nx < 0.66 && ny > 0.27 && ny < 0.73) continue;
|
||||
const index = ((startY + y) * canvasWidth + startX + x) * 4;
|
||||
const alpha = data[index + 3];
|
||||
const key = alpha < TRANSPARENT_ALPHA_THRESHOLD
|
||||
? -1
|
||||
: ((data[index] >> 4) << 8) | ((data[index + 1] >> 4) << 4) | (data[index + 2] >> 4);
|
||||
const bin = bins.get(key) ?? { count: 0, red: 0, green: 0, blue: 0, alpha: 0 };
|
||||
bin.count += 1;
|
||||
bin.red += data[index];
|
||||
bin.green += data[index + 1];
|
||||
bin.blue += data[index + 2];
|
||||
bin.alpha += alpha;
|
||||
bins.set(key, bin);
|
||||
}
|
||||
}
|
||||
const dominant = [...bins.values()].sort((a, b) => b.count - a.count)[0];
|
||||
if (!dominant) return { rgb: [255, 255, 255] as [number, number, number], alpha: 0 };
|
||||
return {
|
||||
rgb: [
|
||||
Math.round(dominant.red / dominant.count),
|
||||
Math.round(dominant.green / dominant.count),
|
||||
Math.round(dominant.blue / dominant.count),
|
||||
] as [number, number, number],
|
||||
alpha: Math.round(dominant.alpha / dominant.count),
|
||||
};
|
||||
}
|
||||
|
||||
function extractCellGlyph(
|
||||
data: Uint8ClampedArray,
|
||||
canvasWidth: number,
|
||||
startX: number,
|
||||
startY: number,
|
||||
cellSize: number,
|
||||
background: [number, number, number],
|
||||
) {
|
||||
const mask = new Uint8Array(cellSize * cellSize);
|
||||
const marginX = Math.max(2, Math.floor(cellSize * 0.12));
|
||||
const marginY = Math.max(2, Math.floor(cellSize * 0.16));
|
||||
for (let y = marginY; y < cellSize - marginY; y++) {
|
||||
for (let x = marginX; x < cellSize - marginX; x++) {
|
||||
const index = ((startY + y) * canvasWidth + startX + x) * 4;
|
||||
if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) continue;
|
||||
const difference = Math.hypot(
|
||||
data[index] - background[0],
|
||||
data[index + 1] - background[1],
|
||||
data[index + 2] - background[2],
|
||||
);
|
||||
if (difference >= 72) mask[y * cellSize + x] = 1;
|
||||
}
|
||||
}
|
||||
return normalizeGlyph(mask, cellSize, cellSize);
|
||||
}
|
||||
|
||||
function recognizeMardCode(glyph: NormalizedGlyph, backgroundLab: Oklab) {
|
||||
let bestCode = "";
|
||||
let bestScore = 0;
|
||||
const candidates = buildMardCodeTemplates()
|
||||
.map((template) => ({ template, distance: oklabDistance(backgroundLab, PALETTE_BY_CODE.get(template.code)!.lab) }))
|
||||
.sort((first, second) => first.distance - second.distance)
|
||||
.slice(0, 24);
|
||||
for (const { template, distance } of candidates) {
|
||||
const rawScore = glyphSimilarity(glyph, template.glyph);
|
||||
const colorPenalty = Math.min(0.16, distance * 0.35);
|
||||
const score = rawScore - colorPenalty;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestCode = template.code;
|
||||
}
|
||||
}
|
||||
return { code: bestCode, score: bestScore };
|
||||
}
|
||||
|
||||
function importMardPatternImage(
|
||||
image: HTMLImageElement,
|
||||
crop: CropRect,
|
||||
width: number,
|
||||
height: number,
|
||||
missingCodeAsEmpty: boolean,
|
||||
) {
|
||||
const maximumSide = 4096;
|
||||
const cellSize = Math.max(10, Math.min(36, Math.floor(maximumSide / Math.max(width, height))));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width * cellSize;
|
||||
canvas.height = height * cellSize;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(
|
||||
image,
|
||||
crop.x * image.naturalWidth,
|
||||
crop.y * image.naturalHeight,
|
||||
crop.width * image.naturalWidth,
|
||||
crop.height * image.naturalHeight,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const pixels: Pixel[] = [];
|
||||
let recognizedCodes = 0;
|
||||
let colorMatches = 0;
|
||||
let emptyCells = 0;
|
||||
const recognitionCache = new Map<string, { code: string; score: number }>();
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let column = 0; column < width; column++) {
|
||||
const startX = column * cellSize;
|
||||
const startY = row * cellSize;
|
||||
const background = dominantCellBackground(data, canvas.width, startX, startY, cellSize);
|
||||
const backgroundLab = rgbToOklab(background.rgb);
|
||||
const glyph = extractCellGlyph(data, canvas.width, startX, startY, cellSize, background.rgb);
|
||||
let recognized: { code: string; score: number } | null = null;
|
||||
if (glyph) {
|
||||
let key = "";
|
||||
for (let index = 0; index < glyph.pixels.length; index++) key += glyph.pixels[index] ? "1" : "0";
|
||||
recognized = recognitionCache.get(key) ?? recognizeMardCode(glyph, backgroundLab);
|
||||
recognitionCache.set(key, recognized);
|
||||
}
|
||||
if (recognized && recognized.score >= 0.58 && PALETTE_BY_CODE.has(recognized.code)) {
|
||||
pixels.push({ color: PALETTE_BY_CODE.get(recognized.code)! });
|
||||
recognizedCodes += 1;
|
||||
continue;
|
||||
}
|
||||
if (missingCodeAsEmpty) {
|
||||
pixels.push({ color: null });
|
||||
emptyCells += 1;
|
||||
continue;
|
||||
}
|
||||
const chroma = Math.hypot(backgroundLab[1], backgroundLab[2]);
|
||||
if (background.alpha < TRANSPARENT_ALPHA_THRESHOLD || (!glyph && backgroundLab[0] > 0.93 && chroma < 0.025)) {
|
||||
pixels.push({ color: null });
|
||||
emptyCells += 1;
|
||||
continue;
|
||||
}
|
||||
pixels.push({ color: nearestColor(backgroundLab, PALETTE) });
|
||||
colorMatches += 1;
|
||||
}
|
||||
}
|
||||
return { pixels, recognizedCodes, colorMatches, emptyCells };
|
||||
}
|
||||
|
||||
function mergePerceptualColors(data: Uint8ClampedArray) {
|
||||
const pixelKeys: Array<number | null> = [];
|
||||
const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>();
|
||||
@@ -755,6 +981,13 @@ export default function Home() {
|
||||
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
|
||||
const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
|
||||
const [cropDialogOpen, setCropDialogOpen] = useState(false);
|
||||
const [cropDialogPurpose, setCropDialogPurpose] = useState<CropDialogPurpose>("source");
|
||||
const [patternImportWidth, setPatternImportWidth] = useState(100);
|
||||
const [patternImportHeight, setPatternImportHeight] = useState(100);
|
||||
const [missingCodeAsEmpty, setMissingCodeAsEmpty] = useState(true);
|
||||
const [patternImportName, setPatternImportName] = useState("");
|
||||
const [patternImportUrl, setPatternImportUrl] = useState("");
|
||||
const [patternImportBusy, setPatternImportBusy] = useState(false);
|
||||
const [pixels, setPixels] = useState<Pixel[]>([]);
|
||||
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
|
||||
const [onlySelected, setOnlySelected] = useState(false);
|
||||
@@ -768,6 +1001,8 @@ export default function Home() {
|
||||
const [status, setStatus] = useState("示例图已准备好,可以直接转换");
|
||||
const sourceImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const patternImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const patternImportImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -791,6 +1026,9 @@ export default function Home() {
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const suppressCanvasClickRef = useRef(false);
|
||||
const cropDialogImage = cropDialogPurpose === "pattern-import"
|
||||
? patternImportImageRef.current
|
||||
: sourceImageRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
logUsage({ event: "page_view" });
|
||||
@@ -914,7 +1152,7 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (!cropDialogOpen) return;
|
||||
const canvas = cropDialogCanvasRef.current;
|
||||
const image = sourceImageRef.current;
|
||||
const image = cropDialogImage;
|
||||
if (!canvas || !image) return;
|
||||
const maximumWidth = 1200;
|
||||
const maximumHeight = 760;
|
||||
@@ -924,7 +1162,7 @@ export default function Home() {
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
}, [cropDialogOpen, sourceUrl]);
|
||||
}, [cropDialogOpen, cropDialogPurpose, sourceUrl, patternImportUrl, cropDialogImage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cropDialogOpen) return;
|
||||
@@ -1107,7 +1345,38 @@ export default function Home() {
|
||||
image.src = url;
|
||||
};
|
||||
|
||||
const handlePatternImportUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setStatus("请选择 JPG、PNG 或 WebP 图纸图片");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
if (patternImportUrl.startsWith("blob:") && patternImportUrl !== sourceUrl) URL.revokeObjectURL(patternImportUrl);
|
||||
patternImportImageRef.current = image;
|
||||
setPatternImportUrl(url);
|
||||
setPatternImportName(file.name);
|
||||
setPatternImportWidth(gridWidth);
|
||||
setPatternImportHeight(gridHeight);
|
||||
setCropDialogPurpose("pattern-import");
|
||||
setCropSelectionMode("free");
|
||||
setDraftCropRect(FULL_CROP);
|
||||
setCropDialogOpen(true);
|
||||
setStatus("图纸图片已载入,请框选实际网格并填写行列数");
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
setStatus("图纸图片读取失败,请换一张图片重试");
|
||||
};
|
||||
image.src = url;
|
||||
};
|
||||
|
||||
const openCropDialog = () => {
|
||||
setCropDialogPurpose("source");
|
||||
setDraftCropRect(cropRect);
|
||||
setCropDialogOpen(true);
|
||||
};
|
||||
@@ -1128,7 +1397,7 @@ export default function Home() {
|
||||
let width = Math.abs(endX - startX);
|
||||
let height = Math.abs(endY - startY);
|
||||
if (cropSelectionMode === "square") {
|
||||
const image = sourceImageRef.current;
|
||||
const image = cropDialogImage;
|
||||
if (!image) return;
|
||||
const normalizedSquareRatio = image.naturalHeight / image.naturalWidth;
|
||||
if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio;
|
||||
@@ -1161,7 +1430,7 @@ export default function Home() {
|
||||
const chooseCropSelectionMode = (mode: CropSelectionMode) => {
|
||||
setCropSelectionMode(mode);
|
||||
if (mode !== "square") return;
|
||||
const image = sourceImageRef.current;
|
||||
const image = cropDialogImage;
|
||||
if (!image) return;
|
||||
const currentCenterX = draftCropRect.x + draftCropRect.width / 2;
|
||||
const currentCenterY = draftCropRect.y + draftCropRect.height / 2;
|
||||
@@ -1267,11 +1536,65 @@ export default function Home() {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const confirmPatternImport = async () => {
|
||||
const image = patternImportImageRef.current;
|
||||
const width = Math.max(1, Math.min(256, Math.round(patternImportWidth)));
|
||||
const height = Math.max(1, Math.min(256, Math.round(patternImportHeight)));
|
||||
if (!image) {
|
||||
setStatus("请先选择要导入的图纸图片");
|
||||
return;
|
||||
}
|
||||
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
|
||||
setStatus("网格区域太小,请重新框选");
|
||||
return;
|
||||
}
|
||||
setPatternImportBusy(true);
|
||||
setStatus("正在读取 MARD 编码并匹配格子底色…");
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
try {
|
||||
const result = importMardPatternImage(image, draftCropRect, width, height, missingCodeAsEmpty);
|
||||
const actualColors = new Set(result.pixels.flatMap(({ color }) => color ? [color.code] : [])).size;
|
||||
const beadCount = result.pixels.reduce((total, { color }) => total + (color ? 1 : 0), 0);
|
||||
setPixels(result.pixels);
|
||||
setGridWidth(width);
|
||||
setGridHeight(height);
|
||||
setRequestedWidth(width);
|
||||
setRequestedHeight(height);
|
||||
setSelectedCodes(new Set());
|
||||
setCropRect(draftCropRect);
|
||||
sourceImageRef.current = image;
|
||||
setSourceUrl(patternImportUrl);
|
||||
setSourceName(`导入图纸:${patternImportName}`);
|
||||
setCropDialogOpen(false);
|
||||
setStatus(`已导入 ${width} × ${height} · 读取编码 ${result.recognizedCodes} 格 · 底色匹配 ${result.colorMatches} 格 · 空白 ${result.emptyCells} 格`);
|
||||
logUsage({
|
||||
event: "pattern_import",
|
||||
width,
|
||||
height,
|
||||
recognized_codes: result.recognizedCodes,
|
||||
color_matches: result.colorMatches,
|
||||
transparent_cells: result.emptyCells,
|
||||
actual_colors: actualColors,
|
||||
bead_count: beadCount,
|
||||
missing_code_as_empty: missingCodeAsEmpty,
|
||||
});
|
||||
window.requestAnimationFrame(() => document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
||||
} catch {
|
||||
setStatus("图纸识别失败,请确认选框与网格行列准确后重试");
|
||||
} finally {
|
||||
setPatternImportBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmCrop = () => {
|
||||
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
|
||||
setStatus("框选区域太小,请在大图上拖出更大的选框");
|
||||
return;
|
||||
}
|
||||
if (cropDialogPurpose === "pattern-import") {
|
||||
void confirmPatternImport();
|
||||
return;
|
||||
}
|
||||
setCropRect(draftCropRect);
|
||||
setCropDialogOpen(false);
|
||||
convertImage(sourceImageRef.current, draftCropRect);
|
||||
@@ -1596,6 +1919,8 @@ export default function Home() {
|
||||
|
||||
<button className="crop-select-button" onClick={openCropDialog}>选择取景区域</button>
|
||||
<button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使用整张图片</button>
|
||||
<button className="pattern-import-button" onClick={() => patternImportInputRef.current?.click()}>导入现成图纸</button>
|
||||
<input ref={patternImportInputRef} className="file-input pattern-import-file" type="file" accept="image/png,image/jpeg,image/webp" onChange={handlePatternImportUpload} />
|
||||
|
||||
<div className="field-row">
|
||||
<label><span>横向格数</span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
|
||||
@@ -1607,7 +1932,7 @@ export default function Home() {
|
||||
<label className="field"><span>取色策略</span><select value={samplingStrategy} onChange={(e) => setSamplingStrategy(e.target.value as SamplingStrategy)}><option value="dominant">区域主色(减少杂色)</option><option value="smooth">平滑取色(细节丰富)</option></select></label>
|
||||
<label className="field"><span>图片适应方式</span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="contain">完整显示</option><option value="cover">裁剪铺满</option></select></label>
|
||||
<button className="primary-button" onClick={() => convertImage()}>重新转换</button>
|
||||
<p className="privacy-note">图片只在当前浏览器中处理,不会上传到服务器。</p>
|
||||
<p className="privacy-note">原图与现成图纸都只在当前浏览器中处理,不会上传到服务器。</p>
|
||||
</aside>
|
||||
|
||||
<section className="pattern-panel" id="pattern-preview">
|
||||
@@ -1693,15 +2018,26 @@ export default function Home() {
|
||||
{cropDialogOpen && <div className="crop-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
|
||||
<section className="crop-dialog" role="dialog" aria-modal="true" aria-labelledby="crop-dialog-title">
|
||||
<div className="crop-dialog-header">
|
||||
<div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title">框选需要转换的区域</h2><p>框内拖动可移动,控制点可调整大小;生成格子时以所选区域正中心为采样基准。</p></div>
|
||||
{cropDialogPurpose === "pattern-import"
|
||||
? <div><p className="section-kicker">MARD PATTERN IMPORT</p><h2 id="crop-dialog-title">导入现成 MARD 图纸</h2><p>选框边缘请贴合实际格子区域,不要包含外侧行列标尺;再填写网格的列数和行数。</p></div>
|
||||
: <div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title">框选需要转换的区域</h2><p>框内拖动可移动,控制点可调整大小;生成格子时以所选区域正中心为采样基准。</p></div>}
|
||||
<button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="crop-mode-bar">
|
||||
<span>选框形状</span>
|
||||
<div className="crop-mode-switch" role="group" aria-label="选框形状">
|
||||
<button className={cropSelectionMode === "free" ? "active" : ""} aria-pressed={cropSelectionMode === "free"} onClick={() => chooseCropSelectionMode("free")}>自由矩形</button>
|
||||
<button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}>正方形</button>
|
||||
</div>
|
||||
{cropDialogPurpose === "pattern-import" ? <>
|
||||
<div className="pattern-import-dimensions">
|
||||
<label><span>横向列数</span><input aria-label="导入图纸横向列数" type="number" min="1" max="256" value={patternImportWidth} onChange={(event) => setPatternImportWidth(Number(event.target.value))} /></label>
|
||||
<b>×</b>
|
||||
<label><span>纵向行数</span><input aria-label="导入图纸纵向行数" type="number" min="1" max="256" value={patternImportHeight} onChange={(event) => setPatternImportHeight(Number(event.target.value))} /></label>
|
||||
</div>
|
||||
<label className="pattern-import-empty-option"><input type="checkbox" checked={missingCodeAsEmpty} onChange={(event) => setMissingCodeAsEmpty(event.target.checked)} /> 没有识别到 MARD 编码的格子视为空白</label>
|
||||
</> : <>
|
||||
<span>选框形状</span>
|
||||
<div className="crop-mode-switch" role="group" aria-label="选框形状">
|
||||
<button className={cropSelectionMode === "free" ? "active" : ""} aria-pressed={cropSelectionMode === "free"} onClick={() => chooseCropSelectionMode("free")}>自由矩形</button>
|
||||
<button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}>正方形</button>
|
||||
</div>
|
||||
</>}
|
||||
<span className="crop-size-readout">当前选框:{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%</span>
|
||||
</div>
|
||||
<div className="crop-dialog-scroll">
|
||||
@@ -1720,6 +2056,11 @@ export default function Home() {
|
||||
<div className="crop-selection-mask crop-selection-right" style={{ top: `${draftCropRect.y * 100}%`, left: `${(draftCropRect.x + draftCropRect.width) * 100}%`, right: 0, height: `${draftCropRect.height * 100}%` }} />
|
||||
<div className="crop-selection-mask crop-selection-bottom" style={{ top: `${(draftCropRect.y + draftCropRect.height) * 100}%` }} />
|
||||
<div className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }}>
|
||||
{cropDialogPurpose === "pattern-import" && patternImportWidth > 0 && patternImportHeight > 0 && <i
|
||||
className="pattern-import-grid-overlay"
|
||||
style={{ backgroundSize: `${100 / patternImportWidth}% 100%, 100% ${100 / patternImportHeight}%` }}
|
||||
aria-hidden="true"
|
||||
/>}
|
||||
{(["nw", "ne", "sw", "se"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
|
||||
{cropSelectionMode === "free" && (["n", "s", "e", "w"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle crop-edge-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
|
||||
</div>
|
||||
@@ -1727,9 +2068,13 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="crop-dialog-actions">
|
||||
<button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}>选择整张图</button>
|
||||
<span>框外拖动可重选,框内拖动可移动,控制点可缩放</span>
|
||||
<span>{cropDialogPurpose === "pattern-import" ? "对齐越准确,格内编码与底色识别越可靠" : "框外拖动可重选,框内拖动可移动,控制点可缩放"}</span>
|
||||
<button onClick={() => setCropDialogOpen(false)}>取消</button>
|
||||
<button className="confirm" disabled={draftCropRect.width < 0.03 || draftCropRect.height < 0.03} onClick={confirmCrop}>使用此区域</button>
|
||||
<button
|
||||
className="confirm"
|
||||
disabled={patternImportBusy || draftCropRect.width < 0.03 || draftCropRect.height < 0.03 || (cropDialogPurpose === "pattern-import" && (patternImportWidth < 1 || patternImportWidth > 256 || patternImportHeight < 1 || patternImportHeight > 256))}
|
||||
onClick={confirmCrop}
|
||||
>{cropDialogPurpose === "pattern-import" ? (patternImportBusy ? "正在识别…" : "识别并导入") : "使用此区域"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>}
|
||||
|
||||
Reference in New Issue
Block a user