From 0a603bca6990f1b03fc845033e3d2629e7d67288 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 21:35:47 +0000 Subject: [PATCH 1/5] Improve no-layout diagnostics and default feasible diameter Co-authored-by: TaylorHere --- archery/functions/api/layout.ts | 4 +-- archery/shared/layout.ts | 51 +++++++++++++++++++++++++++++++++ archery/src/main.ts | 5 ++-- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/archery/functions/api/layout.ts b/archery/functions/api/layout.ts index ca919be..42ef975 100644 --- a/archery/functions/api/layout.ts +++ b/archery/functions/api/layout.ts @@ -1,4 +1,4 @@ -import { solveOptimalLayout, validateInput, type LayoutInput } from '../../shared/layout'; +import { diagnoseNoLayoutReason, solveOptimalLayout, validateInput, type LayoutInput } from '../../shared/layout'; interface JsonPayload { pageWidthMm?: unknown; @@ -34,7 +34,7 @@ export const onRequestPost: PagesFunction = async (context) => { return json({ ok: false, layout: null, - reason: '当前参数不存在满足严格对称约束的 (n, m, s) 组合', + reason: diagnoseNoLayoutReason(input), }); } diff --git a/archery/shared/layout.ts b/archery/shared/layout.ts index e1132d2..40ad722 100644 --- a/archery/shared/layout.ts +++ b/archery/shared/layout.ts @@ -114,6 +114,57 @@ export function solveOptimalLayout(input: LayoutInput): LayoutSolution | null { return best; } +export function diagnoseNoLayoutReason(input: LayoutInput): string { + const validation = validateInput(input); + if (!validation.ok) { + return validation.reason ?? '参数无效'; + } + + const W = input.pageWidthMm; + const H = input.pageHeightMm; + const D = input.targetDiameterMm; + const sMin = input.minSpacingMm; + + // s > 0 时,必须满足 n·D < W 且 m·D < H。 + const maxColumnsWithPositiveSpacing = Math.floor((W - EPS) / D); + const maxRowsWithPositiveSpacing = Math.floor((H - EPS) / D); + if (maxColumnsWithPositiveSpacing < 1 || maxRowsWithPositiveSpacing < 1) { + return '标靶直径过大:页面至少有一个方向连 1 个标靶都无法留出正间距。'; + } + + let hasExactSymmetricPair = false; + let maxFeasibleSpacing = -Infinity; + + for (let n = 1; n <= maxColumnsWithPositiveSpacing; n += 1) { + for (let m = 1; m <= maxRowsWithPositiveSpacing; m += 1) { + const spacingFromWidth = (W - n * D) / (n + 1); + const spacingFromHeight = (H - m * D) / (m + 1); + + if (spacingFromWidth <= EPS || spacingFromHeight <= EPS) { + continue; + } + + if (Math.abs(spacingFromWidth - spacingFromHeight) > EPS) { + continue; + } + + hasExactSymmetricPair = true; + const spacingMm = (spacingFromWidth + spacingFromHeight) / 2; + if (spacingMm > maxFeasibleSpacing) { + maxFeasibleSpacing = spacingMm; + } + } + } + + if (!hasExactSymmetricPair) { + return '当前页面尺寸与直径组合不存在“同一 s 同时满足宽高公式”的严格对称解(与 s_min 无关)。请优先调整直径 D 或页面尺寸。'; + } + + return `存在严格对称解,但可行间距上限约为 ${maxFeasibleSpacing.toFixed(4)} mm,小于当前 s_min=${sMin.toFixed( + 4, + )} mm。请减小 s_min。`; +} + export function getTargetCenterMm( col: number, row: number, diff --git a/archery/src/main.ts b/archery/src/main.ts index 5682a07..d1b3806 100644 --- a/archery/src/main.ts +++ b/archery/src/main.ts @@ -1,6 +1,7 @@ import './style.css'; import { exportArcheryPdf } from './pdf'; import { + diagnoseNoLayoutReason, PAGE_PRESETS_MM, getTargetCenterMm, ringColorByIndex, @@ -46,7 +47,7 @@ app.innerHTML = ` + + + +
@@ -102,6 +118,8 @@ const refs = { minSpacing: getEl('min-spacing'), ringCount: getEl('ring-count'), checkerboard: getEl('checkerboard'), + targetColorMode: getEl('target-color-mode'), + showRingScores: getEl('show-ring-scores'), zoom: getEl('zoom'), zoomLabel: getEl('zoom-label'), previewStage: getEl('preview-stage'), @@ -117,7 +135,9 @@ const refs = { let currentLayout: LayoutSolution | null = null; refs.pagePreset.value = DEFAULT_PAGE_PRESET; +refs.targetColorMode.value = 'bw'; toggleCustomSizeInputs(); +toggleCheckerboardAvailability(); recalculate(); type EditedField = 'page' | 'customWidth' | 'customHeight' | 'diameter' | 'minSpacing' | 'other'; @@ -130,6 +150,8 @@ for (const el of [ refs.minSpacing, refs.ringCount, refs.checkerboard, + refs.targetColorMode, + refs.showRingScores, refs.zoom, ]) { el.addEventListener('input', () => { @@ -138,6 +160,9 @@ for (const el of [ applyPresetToCustomInputs(); toggleCustomSizeInputs(); } + if (el === refs.targetColorMode) { + toggleCheckerboardAvailability(); + } recalculate(editedField); }); } @@ -155,6 +180,8 @@ refs.downloadBtn.addEventListener('click', async () => { diameterMm: getPositiveNumber(refs.diameter), ringCount: getPositiveInteger(refs.ringCount), checkerboardEnabled: refs.checkerboard.checked, + colorMode: getTargetColorMode(), + showRingScores: refs.showRingScores.checked, layout: currentLayout, }); refs.status.textContent = 'PDF 已生成:矢量图可直接打印,请使用 100% 缩放。'; @@ -396,8 +423,11 @@ function renderSvgPreview(layout: LayoutSolution | null, input: LayoutInput): vo } const ringCount = getPositiveInteger(refs.ringCount); - const checkerboardEnabled = refs.checkerboard.checked; + const colorMode = getTargetColorMode(); + const checkerboardEnabled = colorMode === 'bw' && refs.checkerboard.checked; + const showRingScores = refs.showRingScores.checked; const circles: string[] = []; + const scoreLabels: string[] = []; for (let row = 0; row < layout.rows; row += 1) { for (let col = 0; col < layout.columns; col += 1) { @@ -405,11 +435,30 @@ function renderSvgPreview(layout: LayoutSolution | null, input: LayoutInput): vo const startWithBlack = checkerboardEnabled ? (row + col) % 2 === 0 : true; for (let ringIndex = 0; ringIndex < ringCount; ringIndex += 1) { - const radiusMm = (input.targetDiameterMm / 2) * ((ringCount - ringIndex) / ringCount); - const fill = ringColorByIndex(ringIndex, startWithBlack); - const outerStroke = ringIndex === 0 ? ' stroke="#000" stroke-width="0.15"' : ''; + const outerRadiusMm = (input.targetDiameterMm / 2) * ((ringCount - ringIndex) / ringCount); + const innerRadiusMm = + ringIndex === ringCount - 1 ? 0 : (input.targetDiameterMm / 2) * ((ringCount - ringIndex - 1) / ringCount); + const fill = ringFillColor(ringIndex, ringCount, colorMode, startWithBlack); circles.push( - ``, + ``, + ); + + if (!showRingScores) { + continue; + } + + const bandThicknessMm = outerRadiusMm - innerRadiusMm; + if (bandThicknessMm < 0.9) { + continue; + } + + const score = ringScoreByIndex(ringIndex, ringCount); + const labelRadiusMm = (outerRadiusMm + innerRadiusMm) / 2; + const fontSizeMm = Math.min(3.2, Math.max(1.2, bandThicknessMm * 0.9)); + const labelY = center.y - labelRadiusMm + fontSizeMm * 0.35; + const textColor = ringScoreTextColor(fill); + scoreLabels.push( + `${score}`, ); } } @@ -431,6 +480,7 @@ function renderSvgPreview(layout: LayoutSolution | null, input: LayoutInput): vo > ${circles.join('')} + ${scoreLabels.join('')} @@ -468,6 +518,11 @@ function toggleCustomSizeInputs(): void { refs.customHeight.disabled = !isCustom; } +function toggleCheckerboardAvailability(): void { + const isColorMode = getTargetColorMode() === 'color'; + refs.checkerboard.disabled = isColorMode; +} + function applyPresetToCustomInputs(): void { const preset = refs.pagePreset.value as PagePreset; if (preset === 'custom') { @@ -490,6 +545,10 @@ function getPositiveInteger(input: HTMLInputElement): number { return Math.max(1, Math.round(getPositiveNumber(input))); } +function getTargetColorMode(): TargetColorMode { + return refs.targetColorMode.value === 'color' ? 'color' : 'bw'; +} + function getEl(id: string): T { const el = document.getElementById(id); if (!el) { diff --git a/archery/src/pdf.ts b/archery/src/pdf.ts index c12f633..cb14d46 100644 --- a/archery/src/pdf.ts +++ b/archery/src/pdf.ts @@ -1,5 +1,12 @@ import { PDFDocument, rgb } from 'pdf-lib'; -import { getTargetCenterMm, ringColorByIndex, type LayoutSolution } from '../shared/layout'; +import { + getTargetCenterMm, + ringFillColor, + ringScoreByIndex, + ringScoreTextColor, + type LayoutSolution, + type TargetColorMode, +} from '../shared/layout'; export interface PdfRenderInput { pageWidthMm: number; @@ -7,6 +14,8 @@ export interface PdfRenderInput { diameterMm: number; ringCount: number; checkerboardEnabled: boolean; + colorMode: TargetColorMode; + showRingScores: boolean; layout: LayoutSolution; } @@ -40,20 +49,42 @@ export async function exportArcheryPdf(input: PdfRenderInput): Promise { const centerXPt = mmToPt(center.x); const centerYPt = pageHeightPt - mmToPt(center.y); - const startWithBlack = input.checkerboardEnabled ? (row + col) % 2 === 0 : true; + const checkerboardEnabled = input.colorMode === 'bw' && input.checkerboardEnabled; + const startWithBlack = checkerboardEnabled ? (row + col) % 2 === 0 : true; for (let ringIndex = 0; ringIndex < input.ringCount; ringIndex += 1) { - const radiusMm = (input.diameterMm / 2) * ((input.ringCount - ringIndex) / input.ringCount); - const colorHex = ringColorByIndex(ringIndex, startWithBlack); - const isBlack = colorHex === '#000000'; - const color = isBlack ? rgb(0, 0, 0) : rgb(1, 1, 1); + const outerRadiusMm = (input.diameterMm / 2) * ((input.ringCount - ringIndex) / input.ringCount); + const innerRadiusMm = + ringIndex === input.ringCount - 1 ? 0 : (input.diameterMm / 2) * ((input.ringCount - ringIndex - 1) / input.ringCount); + const colorHex = ringFillColor(ringIndex, input.ringCount, input.colorMode, startWithBlack); + const color = hexToRgbColor(colorHex); page.drawCircle({ x: centerXPt, y: centerYPt, - size: mmToPt(radiusMm), + size: mmToPt(outerRadiusMm), color, - borderColor: ringIndex === 0 ? rgb(0, 0, 0) : undefined, - borderWidth: ringIndex === 0 ? mmToPt(0.15) : 0, + borderColor: rgb(0, 0, 0), + borderWidth: mmToPt(0.1), + }); + + if (!input.showRingScores) { + continue; + } + + const bandThicknessMm = outerRadiusMm - innerRadiusMm; + if (bandThicknessMm < 0.9) { + continue; + } + + const score = ringScoreByIndex(ringIndex, input.ringCount); + const labelRadiusMm = (outerRadiusMm + innerRadiusMm) / 2; + const fontSizeMm = Math.min(3.2, Math.max(1.2, bandThicknessMm * 0.9)); + const labelColorHex = ringScoreTextColor(colorHex); + page.drawText(`${score}`, { + x: centerXPt - mmToPt(fontSizeMm * 0.35), + y: centerYPt + mmToPt(labelRadiusMm) - mmToPt(fontSizeMm * 0.35), + size: mmToPt(fontSizeMm), + color: hexToRgbColor(labelColorHex), }); } } @@ -63,3 +94,21 @@ export async function exportArcheryPdf(input: PdfRenderInput): Promise { const fileName = `archery-${input.pageWidthMm}x${input.pageHeightMm}-${input.diameterMm}mm.pdf`; triggerBrowserDownload(bytes, fileName); } + +function hexToRgbColor(hex: string): ReturnType { + const normalized = hex.trim().toLowerCase(); + switch (normalized) { + case '#000000': + return rgb(0, 0, 0); + case '#ffffff': + return rgb(1, 1, 1); + case '#2563eb': + return rgb(37 / 255, 99 / 255, 235 / 255); + case '#dc2626': + return rgb(220 / 255, 38 / 255, 38 / 255); + case '#facc15': + return rgb(250 / 255, 204 / 255, 21 / 255); + default: + return rgb(0, 0, 0); + } +} From 93c0097925c0056094b12ae27bb8d90159c7855d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 21:54:35 +0000 Subject: [PATCH 5/5] Add auto-fill and target-count layout interaction Co-authored-by: TaylorHere --- archery/functions/api/layout.ts | 4 ++ archery/shared/layout.ts | 97 +++++++++++++++++++++++++++------ archery/src/main.ts | 52 +++++++++++++++++- 3 files changed, 135 insertions(+), 18 deletions(-) diff --git a/archery/functions/api/layout.ts b/archery/functions/api/layout.ts index 42ef975..67b32da 100644 --- a/archery/functions/api/layout.ts +++ b/archery/functions/api/layout.ts @@ -5,6 +5,8 @@ interface JsonPayload { pageHeightMm?: unknown; targetDiameterMm?: unknown; minSpacingMm?: unknown; + layoutMode?: unknown; + desiredTargets?: unknown; } export const onRequestPost: PagesFunction = async (context) => { @@ -15,6 +17,8 @@ export const onRequestPost: PagesFunction = async (context) => { pageHeightMm: Number(payload.pageHeightMm), targetDiameterMm: Number(payload.targetDiameterMm), minSpacingMm: Number(payload.minSpacingMm), + layoutMode: payload.layoutMode === 'target_count' ? 'target_count' : 'auto_fill', + desiredTargets: payload.desiredTargets === undefined ? undefined : Number(payload.desiredTargets), }; const validation = validateInput(input); diff --git a/archery/shared/layout.ts b/archery/shared/layout.ts index cff5b8b..f29c56d 100644 --- a/archery/shared/layout.ts +++ b/archery/shared/layout.ts @@ -1,11 +1,14 @@ export type PagePreset = 'A4' | 'A3' | 'custom'; export type TargetColorMode = 'bw' | 'color'; +export type LayoutMode = 'auto_fill' | 'target_count'; export interface LayoutInput { pageWidthMm: number; pageHeightMm: number; targetDiameterMm: number; minSpacingMm: number; + layoutMode?: LayoutMode; + desiredTargets?: number; } export interface LayoutSolution { @@ -46,6 +49,14 @@ export function validateInput(input: LayoutInput): ValidationResult { } } + const mode = normalizeLayoutMode(input.layoutMode); + if (mode === 'target_count') { + const desiredTargets = Number(input.desiredTargets); + if (!Number.isFinite(desiredTargets) || desiredTargets <= 0) { + return { ok: false, reason: '指定数量模式下 desiredTargets 必须是大于 0 的数字' }; + } + } + return { ok: true }; } @@ -66,7 +77,7 @@ export function solveOptimalLayout(input: LayoutInput): LayoutSolution | null { return null; } - let best: LayoutSolution | null = null; + const candidates: LayoutSolution[] = []; // 穷举整数列数 n、行数 m,严格约束同一个 s 同时满足宽高公式: // W = nD + (n+1)s @@ -99,25 +110,20 @@ export function solveOptimalLayout(input: LayoutInput): LayoutSolution | null { totalTargets, }; - if (!best) { - best = candidate; - continue; - } - - // 主目标:最大化数量。并列时优先更大间距,再优先更多列(使预览更“横向密集”)。 - if ( - candidate.totalTargets > best.totalTargets || - (candidate.totalTargets === best.totalTargets && candidate.spacingMm > best.spacingMm + EPS) || - (candidate.totalTargets === best.totalTargets && - Math.abs(candidate.spacingMm - best.spacingMm) <= EPS && - candidate.columns > best.columns) - ) { - best = candidate; - } + candidates.push(candidate); } } - return best; + if (candidates.length === 0) { + return null; + } + + const mode = normalizeLayoutMode(input.layoutMode); + if (mode === 'target_count') { + return pickClosestByDesiredTargets(candidates, Math.max(1, Math.round(Number(input.desiredTargets)))); + } + + return pickAutoFillBest(candidates); } export function diagnoseNoLayoutReason(input: LayoutInput): string { @@ -304,3 +310,60 @@ export function ringScoreTextColor(fillHex: string): '#000000' | '#FFFFFF' { } return '#000000'; } + +function normalizeLayoutMode(mode?: LayoutMode): LayoutMode { + return mode === 'target_count' ? 'target_count' : 'auto_fill'; +} + +function pickAutoFillBest(candidates: LayoutSolution[]): LayoutSolution { + let best = candidates[0]; + for (let i = 1; i < candidates.length; i += 1) { + const candidate = candidates[i]; + if ( + candidate.totalTargets > best.totalTargets || + (candidate.totalTargets === best.totalTargets && candidate.spacingMm > best.spacingMm + EPS) || + (candidate.totalTargets === best.totalTargets && + Math.abs(candidate.spacingMm - best.spacingMm) <= EPS && + candidate.columns > best.columns) + ) { + best = candidate; + } + } + return best; +} + +function pickClosestByDesiredTargets(candidates: LayoutSolution[], desiredTargets: number): LayoutSolution { + let best = candidates[0]; + for (let i = 1; i < candidates.length; i += 1) { + const candidate = candidates[i]; + const candidateDiff = Math.abs(candidate.totalTargets - desiredTargets); + const bestDiff = Math.abs(best.totalTargets - desiredTargets); + if (candidateDiff < bestDiff - EPS) { + best = candidate; + continue; + } + if (Math.abs(candidateDiff - bestDiff) > EPS) { + continue; + } + + // 差距相同,优先不低于目标数量。 + const candidateMeetsTarget = candidate.totalTargets >= desiredTargets; + const bestMeetsTarget = best.totalTargets >= desiredTargets; + if (candidateMeetsTarget && !bestMeetsTarget) { + best = candidate; + continue; + } + if (!candidateMeetsTarget && bestMeetsTarget) { + continue; + } + + // 再并列时优先更大间距和更多列。 + if ( + candidate.spacingMm > best.spacingMm + EPS || + (Math.abs(candidate.spacingMm - best.spacingMm) <= EPS && candidate.columns > best.columns) + ) { + best = candidate; + } + } + return best; +} diff --git a/archery/src/main.ts b/archery/src/main.ts index e99fad4..8097641 100644 --- a/archery/src/main.ts +++ b/archery/src/main.ts @@ -2,6 +2,7 @@ import './style.css'; import { exportArcheryPdf } from './pdf'; import { diagnoseNoLayoutReason, + type LayoutMode, PAGE_PRESETS_MM, getTargetCenterMm, ringFillColor, @@ -60,6 +61,19 @@ app.innerHTML = ` + + + +