import type { AnnotationData, AnnotationType, ShapeData, StampData } from "@/db/schema"; import { clamp, pointToPx, rectToPx, toPx, type Point, type Rect, type Size, } from "@/utils/coordinates"; // Geometry ของ annotation (PLAN-FEAT.md §3 F1) — pure functions ทดสอบได้ // ข้อมูลเข้า/ออกเป็นพิกัด % ตาม invariant #2 ยกเว้นฟังก์ชันที่รับ `size` // เพื่อแปลง % → px (เช่น bbox สำหรับวาดกรอบ selection, hit-test หน่วย px) /** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data * เป็นค่าประมาณ % สำหรับ hit-test ที่ไม่รู้ขนาด render เท่านั้น — ทุกจุดที่รู้ขนาดหน้าจริง * (กรอบ selection / scroll target / move-clamp) ใช้ commentPinBoxPx/commentPinBoxPct ตาม px จริงข้างล่าง */ export const COMMENT_PIN_BOX_PCT = 4; /** COMMENT: ขนาด px จริงของปุ่ม pin — desktop 26px (icon 16 + p-1 รอบด้าน + border 1×2) * และจอสัมผัส 36px (.coarse-36 ใน globals.css บังคับ min-width/height 2.25rem ให้ปุ่ม * pin ใน PDFViewer) — pin เป็น px คงที่ไม่ scale ตาม zoom ต่างจากชนิดอื่นที่เป็น % ทั้งหมด * (bug report 2026-09-23: กรอบไม่ตรงกล่อง + responsive audit 2026-09-24: scroll/clamp * คิดจาก % เดิมจน pin จมใต้ header ตอนซูมออก) — แก้ปุ่ม pin หรือ .coarse-36 ต้องแก้ค่านี้ตาม */ export const COMMENT_PIN_SIZE_PX = 26; export const COMMENT_PIN_COARSE_SIZE_PX = 36; /** bbox px ของ COMMENT ตามขนาด render จริง — จัตุรัส px คงที่ กึ่งกลางแนวนอนที่จุด position * และขอบล่างชนจุด (สูตรเดียวกับ -translate-x-1/2 -translate-y-full ของปุ่ม pin — ทั้งลูก * ลอยเหนือจุดคลิก) */ export function commentPinBoxPx(d: { position: Point }, size: Size, pinPx: number): Rect { const p = pointToPx(d.position, size); return { x: p.x - pinPx / 2, y: p.y - pinPx, width: pinPx, height: pinPx }; } /** bbox ของ COMMENT หน่วย % คิดจากขนาด render จริง (size = px ของหน้าที่กำลัง render) — * ใช้กับ scroll target ของ goToAnnotation/deep link + move-clamp/bookmark ของกรอบ selection * ให้เป็นมาตราฐานเดียวกับที่ render (responsive audit 2026-09-24 — เดิมสองมาตราฐาน: * render ใช้ px แต่ scroll/clamp ยังใช้ COMMENT_PIN_BOX_PCT ของ annotationBBox) */ export function commentPinBoxPct(d: { position: Point }, size: Size, pinPx: number): Rect { const wPct = (pinPx / size.width) * 100; const hPct = (pinPx / size.height) * 100; return { x: d.position.x - wPct / 2, y: d.position.y - hPct, width: wPct, height: hPct }; } // TEXTBOX (F17/B2 — §5): ค่าประมาณสำหรับ bbox ของกล่องข้อความ // render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร // กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection) export const TEXTBOX_MAX_WIDTH_PCT = 50; export const TEXTBOX_DEFAULT_WIDTH_PCT = 30; // กล่องที่สร้างใหม่ (คลิกเดียว) กว้างเท่านี้ก่อนลากขยาย export const TEXTBOX_CHAR_W = 0.6; export const TEXTBOX_LINE_H = 1.5; /** ความกว้างสูงสุดของกล่องข้อความ (% ของความกว้างหน้า) จากขอบซ้าย (position.x = มุมซ้ายบน * เสมอ — align จัดข้อความ*ใน*กล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ) — บีบให้กล่อง * ไม่ล้นขอบขวาของหน้ากระดาษ ใช้ร่วมกันทั้ง render จอ, bbox กรอบ selection และ export PDF * ติดขอบขวาเป๊ะ (x=100) เหลือกว้างขั้นต่ำ 1% กันกล่องพังเป็นศูนย์ */ export function textboxMaxWidthPct(leftX: number): number { return Math.min(TEXTBOX_MAX_WIDTH_PCT, Math.max(100 - clamp(leftX, 0, 100), 1)); } // STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า — สูตรจัดวางข้อความทั้งชุด // (บรรทัด/ฟอนต์/ความสูงกรอบ) อยู่ที่นี่ใช้ร่วมทั้ง render บนจอ, preview ในเมนู, bbox ของ // selection และการวาดใน export — utils/annotation-display.ts re-export ตัวที่เดิมอยู่ที่นั่น /** ความสูงกรอบตรายางขั้นต่ำ (คำสั้น 1 บรรทัดคงสัดส่วนหน้าตาเดิม) — สัดส่วนของความกว้างกรอบ */ export const STAMP_HEIGHT_RATIO = 0.4; /** ขนาดตัวอักษรในตรายาง = สัดส่วนของความกว้างกรอบ (ค่าสูงสุด — ย่อลงเมื่อข้อความยาว) */ export const STAMP_FONT_RATIO = 0.22; /** ความกว้างข้อความสูงสุดที่ยอมให้ต่อบรรทัด = สัดส่วนของกรอบ (เว้นระยะจากขอบใน) */ export const STAMP_TEXT_WIDTH_RATIO = 0.9; /** จำนวนบรรทัดสูงสุดของข้อความตรายาง (2026-09-28) — เกินกว่านี้ย่อฟอนต์ต่อจนเข้าเกณฑ์ */ export const STAMP_MAX_LINES = 3; /** เพดานล่างของฟอนต์ (สัดส่วนของความกว้างกรอบ) — ต่ำกว่านี้อ่านไม่ออก ยอมให้บรรทัดเกินแทน */ export const STAMP_FONT_FLOOR_RATIO = 0.1; /** ความกว้างเฉลี่ยต่ออักษร (สัดส่วนของฟอนต์) — measure ประมาณเมื่อไม่มี measure จริง (bbox) */ const STAMP_CHAR_W = 0.58; /** ขนาดตัวอักษรตรายางที่ label อยู่บรรทัดเดียว — เริ่มที่ STAMP_FONT_RATIO แล้วย่อตาม * สัดส่วนที่ข้อความล้น (measure ผูกกับเชิงเส้นกับขนาดฟอนต์ วัดครั้งเดียวพอ) — measure เป็น * ความกว้างจริงของฝั่งนั้น ๆ: จอ = canvas measureText, export = font.widthOfTextAtSize */ export function stampFitFontSize( label: string, boxWidth: number, measure: (text: string, fontSize: number) => number, ): number { const base = boxWidth * STAMP_FONT_RATIO; const textW = measure(label, base); const maxW = boxWidth * STAMP_TEXT_WIDTH_RATIO; return textW <= maxW ? base : (base * maxW) / textW; } /** ผลการจัดวางข้อความตรายางในกรอบ — บรรทัดที่ตัดแล้ว + ขนาดฟอนต์ + lineHeight */ export type StampLayout = { lines: string[]; fontSize: number; lineHeight: number }; /** ตัด label เป็นบรรทัด ๆ กว้างไม่เกิน maxWidth — เว้นคำที่ช่องว่าง ส่วนคำเดี่ยวยาวเกิน * (ไทยไม่มีช่องว่างคั่น) ตัดที่ตัวอักษรตรง ๆ */ export function wrapStampLabel( label: string, fontSize: number, maxWidth: number, measure: (text: string, fontSize: number) => number, ): string[] { const lines: string[] = []; let cur = ""; for (const word of label.split(/\s+/)) { if (!word) continue; // คำเดี่ยวยาวเกินบรรทัด — ตัดที่อักษร if (measure(word, fontSize) > maxWidth) { for (const ch of word) { if (cur && measure(cur + ch, fontSize) > maxWidth) { lines.push(cur); cur = ""; } cur += ch; } continue; } const candidate = cur ? `${cur} ${word}` : word; if (measure(candidate, fontSize) <= maxWidth) cur = candidate; else { lines.push(cur); cur = word; } } if (cur) lines.push(cur); return lines.length ? lines : [""]; } /** จัดวางข้อความตรายางในกรอบกว้าง boxWidth (2026-09-28): คำสั้น = บรรทัดเดียวฟอนต์เต็ม * (พฤติกรรมเดิม) — คำยาวพอดี ๆ = **ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน** ลงได้จนถึง * STAMP_FONT_FLOOR_RATIO (คำอย่าง "ตรวจสอบแล้ว" ย่อ ~25% ก็พอ จะได้บรรทัดเดียว * กลับคืน ไม่ตก 2 บรรทัด — คำขอผู้ใช้ 2026-09-28 รอบสอง) — คำยาวจนย่อถึง floor แล้ว * ยังไม่พอ = กลับไปที่ฟอนต์ใหญ่แล้ว wrap หลายบรรทัด (ไม่เกิน STAMP_MAX_LINES) ให้ * ฟอนต์ใหญ่กว่าการย่อบรรทัดเดียวจนแทบอ่านไม่ออก — ไล่ขนาดฟอนต์จากใหญ่ไปเล็ก * (×0.92 ต่อรอบ) จนจำนวนบรรทัดเข้าเกณฑ์ หรือถึงเพดานล่าง (ยอมให้เกินบรรทัด * แลกกับฟอนต์ที่ยังอ่านออก) */ export function stampLayout( label: string, boxWidth: number, measure: (text: string, fontSize: number) => number, ): StampLayout { const base = boxWidth * STAMP_FONT_RATIO; const maxW = boxWidth * STAMP_TEXT_WIDTH_RATIO; const floor = boxWidth * STAMP_FONT_FLOOR_RATIO; const single = stampFitFontSize(label, boxWidth, measure); // ย่อฟอนต์เป็นบรรทัดเดียวได้โดยไม่ต่ำกว่า floor → บรรทัดเดียว (เจตนาเดิมของ // F18 "ยาวอย่าง 'ตรวจสอบแล้ว' ไม่ wrap" — การ wrap ที่ฟอนต์เต็มทันทีทำคำพอดี ๆ // ตกบรรทัดโดยไม่จำเป็น) — single ≤ base เสมอ (stampFitFontSize คืน base เมื่อ // พอดี หรือ base×maxW/textW < base เมื่อล้น) จึงใช้ตรง ๆ ได้ if (single >= floor) return { lines: [label], fontSize: single, lineHeight: 1.2 }; // ย่อจนต่ำกว่า floor = อ่านไม่ออก — ฟอนต์กลับไปใหญ่แล้ว wrap หลายบรรทัดแทน let fontSize = base; let lines = wrapStampLabel(label, fontSize, maxW, measure); while (lines.length > STAMP_MAX_LINES && fontSize * 0.92 >= floor) { fontSize *= 0.92; lines = wrapStampLabel(label, fontSize, maxW, measure); } return { lines, fontSize, lineHeight: 1.2 }; } /** ความสูงกรอบตรายางจาก layout — ข้อความหลายบรรทัดสูงขึ้นตามจำนวนบรรทัด (พื้นที่ว่าง * บน+ล่างรวม 13.6% ของความกว้าง = 0.4w เดิม − ข้อความ 1 บรรทัดที่ฟอนต์เต็ม ทำให้คำ * สั้น 1 บรรทัดสูง 0.4w เป๊ะเหมือนก่อนมีการ wrap) แต่ไม่ต่ำกว่า STAMP_HEIGHT_RATIO */ export function stampBoxHeight(layout: StampLayout, boxWidth: number): number { const textH = layout.lines.length * layout.fontSize * layout.lineHeight; return Math.max(boxWidth * STAMP_HEIGHT_RATIO, textH + boxWidth * 0.136); } /** จัดวางข้อความตรายางแบบไม่ต้องมี measure จริง (ใช้ความกว้างเฉลี่ยต่ออักษร) — สำหรับ * annotationBBox/hit-test ที่เป็น pure function รันได้ทั้งฝั่ง server — ผลใกล้เคียง * ของจริงพอสำหรับการชี้เลือก (จอจริงวัดด้วย measureStampText ของ annotation-display) */ export function stampLayoutEstimate(label: string, boxWidth: number): StampLayout { return stampLayout(label, boxWidth, (text, fs) => text.length * fs * STAMP_CHAR_W); } /** กรอบตรายาง (ก่อนหมุน) + มุมเอียง หน่วย px ตาม size — single source ให้ทั้ง * annotationBBox (ครอบเป็น AABB ของกรอบหมุนแล้ว) และกรอบ selection ที่วาดกรอบ * หมุนจริง (ต้องการกรอบเดิม + มุมแยกกันจึงใช้ CSS transform หมุนรอบกลาง) */ export function stampRect( d: Pick, size: Size, measure?: (text: string, fontSize: number) => number, ): Rect & { rotation: number } { const w = toPx(d.scale, size.width); const p = pointToPx(d.position, size); const lay = measure ? stampLayout(d.label, w, measure) : stampLayoutEstimate(d.label, w); return { x: p.x, y: p.y, width: w, height: stampBoxHeight(lay, w), rotation: d.rotation ?? 0, }; } /** หมุนจุดรอบ center ด้วยองศา (บวก = ตามเข็มนาฬิกา ตาม CSS rotate) — ใช้แปลงพิกัด * มุมกรอบตรายางเอียง (anchor ของ handle scale) และแปลง pointer เข้า frame ของกรอบ * (ส่ง deg เป็นลบ = ย้อนหมุน) — หน่วยใดก็ได้ตรงกันทั้งคู่ (% หรือ px) */ export function rotatePoint(p: Point, center: Point, deg: number): Point { if (!deg) return p; const rad = (deg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const dx = p.x - center.x; const dy = p.y - center.y; return { x: center.x + dx * cos - dy * sin, y: center.y + dx * sin + dy * cos }; } /** เลื่อน position ของตรายาง (%, มุมกรอบ*ก่อนหมุน*) แค่น้อยที่สุดให้ AABB ของกรอบ * หมุนแล้วกลับเข้า 0–100 ทุกด้าน — ใช้ตอนตั้งมุมเอียงใหม่ (patch/ลากหมุน): หมุนที่ * ตำแหน่งชิดขอบแล้วกรอบอาจยื่นเกินขอบหน้า ต้องดึงกลับทันที ไม่ใช่รอให้เด้งตอน * ลากครั้งถัดไป (code review 2026-09-28) — rotation = 0 กลางหน้า = ไม่ขยับ */ export function clampStampIntoPage( d: Pick, ): Point { const aabb = rotatedRectAABB(stampRect(d, { width: 100, height: 100 })); const dx = aabb.x < 0 ? -aabb.x : aabb.x + aabb.width > 100 ? 100 - (aabb.x + aabb.width) : 0; const dy = aabb.y < 0 ? -aabb.y : aabb.y + aabb.height > 100 ? 100 - (aabb.y + aabb.height) : 0; return { x: d.position.x + dx, y: d.position.y + dy }; } /** AABB (กรอบตรงแกน) ของ rect หลังหมุน `rotation` องศารอบ**กลางกรอบ** — สำหรับ * hit-test/tooltip/clamp ของตรายางเอียง: กรอบจริงเอียง AABB จึงกว้าง/สูงกว่ากรอบ * เดิม (ที่ 45° ใหญ่สุด = แนวทะเลียบ) — จุดที่อยู่ใน AABB แต่นอกกรอบเอียง = โดน * เผื่อไว้ ยอมรับได้เพราะ handle ลากก็ใช้พื้นที่รอบกรอบอยู่แล้ว */ export function rotatedRectAABB(r: Rect & { rotation: number }): Rect { const rad = (r.rotation * Math.PI) / 180; const cos = Math.abs(Math.cos(rad)); const sin = Math.abs(Math.sin(rad)); const w = r.width * cos + r.height * sin; const h = r.width * sin + r.height * cos; const cx = r.x + r.width / 2; const cy = r.y + r.height / 2; return { x: cx - w / 2, y: cy - h / 2, width: w, height: h }; } type AnnotationLike = { type: AnnotationType; data: AnnotationData }; /** * bbox ของ annotation (หน่วย px ตาม size ที่ส่งเข้ามา) * - HIGHLIGHT: union ของ rects ทุกอัน * - DRAW: union ของจุดทุกจุด ขยายออกด้วย strokeWidth/2 (เส้นมีความหนา) * - COMMENT: กล่องสี่เหลี่ยมขนาด COMMENT_PIN_BOX_PCT (% ของความกว้างหน้า) รอบ position * เคล็ดลับ: ส่ง size={width:100, height:100} เพื่อให้ผลลัพธ์เป็น "%" โดยตรง */ export function annotationBBox( a: AnnotationLike, size: Size, /** measure ความกว้างข้อความตรายาง — ไม่ส่ง = ประมาณด้วย STAMP_CHAR_W (stampLayoutEstimate, * รันได้ฝั่ง server) · ฝั่ง client (กรอบ selection, hit-test, tooltip, eraser) ต้องส่ง * measureStampText ให้สูงกรอบตรงกับที่ StampItem render จริง — ประมาณแล้วจำนวนบรรทัด * คลาดตามฟอนต์ของแต่ละเครื่อง จนขอบล่างกรอบเกิน/สั้นกว่าตรายาง (bug 2026-09-28) */ measure?: (text: string, fontSize: number) => number, ): Rect { const d = a.data; // union ไม่ได้ discriminate โดย field ใน data เอง → แยกตาม key เฉพาะของแต่ละ variant if ("rects" in d) { const rects = d.rects.map((r) => rectToPx(r, size)); if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 }; const x1 = Math.min(...rects.map((r) => r.x)); const y1 = Math.min(...rects.map((r) => r.y)); const x2 = Math.max(...rects.map((r) => r.x + r.width)); const y2 = Math.max(...rects.map((r) => r.y + r.height)); return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; } if ("paths" in d) { const pts = d.paths.map((p) => pointToPx(p, size)); const half = toPx(d.strokeWidth, size.width) / 2; const x1 = Math.min(...pts.map((p) => p.x)) - half; const y1 = Math.min(...pts.map((p) => p.y)) - half; const x2 = Math.max(...pts.map((p) => p.x)) + half; const y2 = Math.max(...pts.map((p) => p.y)) + half; return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; } if ("shape" in d) { const half = toPx(d.strokeWidth, size.width) / 2; if ("rect" in d) { const r = rectToPx(d.rect, size); return { x: r.x - half, y: r.y - half, width: r.width + half * 2, height: r.height + half * 2, }; } const a1 = pointToPx(d.from, size); const b1 = pointToPx(d.to, size); const x1 = Math.min(a1.x, b1.x) - half; const y1 = Math.min(a1.y, b1.y) - half; const x2 = Math.max(a1.x, b1.x) + half; const y2 = Math.max(a1.y, b1.y) + half; return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; } if ("rect" in d) { // REDACTION — กรอบดำทึบ ไม่มีความหนาเส้นให้ขยาย return rectToPx(d.rect, size); } if ("fontSize" in d) { // TEXTBOX — position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียว); // กล่องมี width คงที่ (ถ้ามี — สไตล์ Smallpdf) ไม่มี = shrink-to-fit ประมาณจากตัวอักษร const fontSizePx = toPx(d.fontSize, size.width); const charW = fontSizePx * TEXTBOX_CHAR_W; const lineH = fontSizePx * TEXTBOX_LINE_H; // ความกว้างสูงสุดถึงขอบขวาหน้า — กล่อง width คงที่ก็ถูกบีบด้วยค่านี้เหมือน render const capPct = textboxMaxWidthPct(d.position.x); const wrapPx = toPx(Math.min(d.width ?? capPct, capPct), size.width); const wrapW = Math.max(wrapPx, charW); // กันหารศูนย์เมื่อกล่องติดขอบขวาเป๊ะ let widest = 0; let lines = 0; for (const raw of d.text.split(/\r?\n/)) { widest = Math.max(widest, raw.length * charW); lines += Math.max(1, Math.ceil((raw.length * charW) / wrapW)); } // กล่อง width คงที่กว้างตามที่กำหนด (บีบด้วย cap) — แถวเดิม shrink-to-fit ตามบรรทัดยาวสุด const width = (d.width !== undefined ? wrapPx : Math.min(widest, wrapPx)) || charW; const p = pointToPx(d.position, size); return { x: p.x, y: p.y, width, height: Math.max(lines, 1) * lineH }; } if ("scale" in d) { // STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน) หมุนรอบกลางกรอบตาม // rotation (ไม่มี = 0°) — คืน AABB ของกรอบหมุนแล้ว (hit-test/tooltip/clamp เป็น // แกนตรง ส่วนกรอบ selection ใช้ stampRect วาดกรอบหมุนจริง) const r = stampRect(d, size, measure); return r.rotation ? rotatedRectAABB(r) : { x: r.x, y: r.y, width: r.width, height: r.height }; } const p = pointToPx(d.position, size); // กล่องรอบ pin อิง % ของความกว้างหน้า — สอดคล้องทั้ง render (px) และ space % // (size={100,100}) ไม่เกิดหน่วยเพี้ยนตอน clamp translate/scale ของ COMMENT const box = toPx(COMMENT_PIN_BOX_PCT, size.width); return { x: p.x - box / 2, y: p.y - box / 2, width: box, height: box }; } /** * เลื่อน annotation ทั้งชิ้นตาม offset (หน่วย % ของหน้า) — ใช้ตอนลากย้าย * strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา) * ผู้เรียกต้อง clamp dx/dy เองให้ bbox ยังอยู่ใน 0–100 ก่อนส่งเข้า action */ export function translateAnnotationData( data: AnnotationData, dxPct: number, dyPct: number, ): AnnotationData { if ("rects" in data) { return { ...data, rects: data.rects.map((r) => ({ ...r, x: r.x + dxPct, y: r.y + dyPct })), }; } if ("paths" in data) { return { ...data, paths: data.paths.map((p) => ({ x: p.x + dxPct, y: p.y + dyPct })), }; } if ("shape" in data) { if ("rect" in data) { return { ...data, rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct }, }; } return { ...data, from: { x: data.from.x + dxPct, y: data.from.y + dyPct }, to: { x: data.to.x + dxPct, y: data.to.y + dyPct }, }; } if ("rect" in data) { // REDACTION return { ...data, rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct }, }; } return { ...data, position: { x: data.position.x + dxPct, y: data.position.y + dyPct }, }; } /** * ย่อ–ขยาย annotation รอบจุด anchor (หน่วย % ของหน้า) ด้วยตัวคูณแกน x/y * — ใช้ตอนลากมุมกรอบ (anchor = มุมตรงข้าม) ครอบ rects ทุกอัน / จุดทุกจุด / position * strokeWidth คงเดิมตามแผน (สเปก F1 ระบุแค่ rects/points/position) */ export function scaleAnnotationData( data: AnnotationData, anchorPct: Point, sx: number, sy: number, ): AnnotationData { const fx = (x: number) => anchorPct.x + (x - anchorPct.x) * sx; const fy = (y: number) => anchorPct.y + (y - anchorPct.y) * sy; if ("rects" in data) { return { ...data, rects: data.rects.map((r) => ({ x: fx(r.x), y: fy(r.y), width: r.width * sx, height: r.height * sy, })), }; } if ("paths" in data) { return { ...data, paths: data.paths.map((p) => ({ x: fx(p.x), y: fy(p.y) })), }; } if ("shape" in data) { if ("rect" in data) { return { ...data, rect: { x: fx(data.rect.x), y: fy(data.rect.y), width: data.rect.width * sx, height: data.rect.height * sy, }, }; } return { ...data, from: { x: fx(data.from.x), y: fy(data.from.y) }, to: { x: fx(data.to.x), y: fy(data.to.y) }, }; } if ("rect" in data) { // REDACTION return { ...data, rect: { x: fx(data.rect.x), y: fy(data.rect.y), width: data.rect.width * sx, height: data.rect.height * sy, }, }; } if ("scale" in data) { // STAMP — ขนาดทั้งกว้าง-สูงเก็บใน scale % ตัวเดียว (สัดส่วนตายตัว // STAMP_HEIGHT_RATIO) จึงขยายด้วยตัวคูณเดียว: caller ส่ง sx = sy = k return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) }, scale: data.scale * sx, }; } return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) } }; } function distToSegment(p: Point, a: Point, b: Point): number { const dx = b.x - a.x; const dy = b.y - a.y; const lenSq = dx * dx + dy * dy; if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y); const t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq, 0, 1); return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); } /** * hit-test จุด (px) กับ polyline (% ของหน้า) — ระยะถึง segment ใด ๆ ≤ thresholdPx * ใช้ใน F6 (eraser); threshold หน่วย px เพราะความคาดหวังของผู้ใช้อิงขนาดจอ ไม่ใช่ % ของหน้า */ export function hitTestPath( pointsPct: Point[], pointPct: Point, thresholdPx: number, size: Size, ): boolean { if (pointsPct.length === 0) return false; const pts = pointsPct.map((p) => pointToPx(p, size)); const pt = pointToPx(pointPct, size); if (pts.length === 1) return Math.hypot(pt.x - pts[0].x, pt.y - pts[0].y) <= thresholdPx; for (let i = 0; i < pts.length - 1; i++) { if (distToSegment(pt, pts[i], pts[i + 1]) <= thresholdPx) return true; } return false; } /** ระยะจากจุดถึงเส้นขอบสี่เหลี่ยม (px) — อยู่ใน "พื้นที่" กรอบจะได้ระยะถึงขอบ ไม่ใช่ 0 */ function distToRectOutline(p: Point, r: Rect): number { const inside = p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height; if (inside) { const dx = Math.min(p.x - r.x, r.x + r.width - p.x); const dy = Math.min(p.y - r.y, r.y + r.height - p.y); return Math.min(dx, dy); } const cx = Math.max(r.x, Math.min(p.x, r.x + r.width)); const cy = Math.max(r.y, Math.min(p.y, r.y + r.height)); return Math.hypot(p.x - cx, p.y - cy); } /** ประมาณระยะจากจุดถึงเส้นรอบวงรี (px) — scale-space: |r−1|×min(rx,ry) พอสำหรับ eraser */ function distToEllipseOutline(p: Point, r: Rect): number { const cx = r.x + r.width / 2; const cy = r.y + r.height / 2; const rx = r.width / 2; const ry = r.height / 2; if (rx === 0 || ry === 0) return Math.hypot(p.x - cx, p.y - cy); const ratio = Math.hypot((p.x - cx) / rx, (p.y - cy) / ry); return Math.abs(ratio - 1) * Math.min(rx, ry); } /** * hit-test จุดกับรูปทรง (F6 eraser) — เฉพาะ "เส้น stroke/ขอบ" เท่านั้น * ผ่านพื้นที่ตรงกลางรูปทรงไม่โดน (PLAN-FEAT.md §5) */ export function hitTestShape( data: ShapeData, pointPct: Point, thresholdPx: number, size: Size, ): boolean { const p = pointToPx(pointPct, size); if ("rect" in data) { const r = rectToPx(data.rect, size); if (data.shape === "ellipse") return distToEllipseOutline(p, r) <= thresholdPx; return distToRectOutline(p, r) <= thresholdPx; } const a = pointToPx(data.from, size); const b = pointToPx(data.to, size); return distToSegment(p, a, b) <= thresholdPx; }