"use strict"; // Layer: ui/physics-shape-editor // Shared button labels/templates for rotator, reciprocator, and poison-block // segment editors. Low-level editor pointer behavior remains in placement log. (function (global) { const MODE_LABELS = Object.freeze({ line: "\uD83D\uDCCF \u76F4\u7DDA", free: "\u270E \u81EA\u7531\u63CF\u753B", erase: "\u232B \u6D88\u3057\u30B4\u30E0", }); const TEMPLATE_LABELS = Object.freeze({ bar: "\u2503 \u68D2", cross: "\uFF0B \u5341\u5B57", circle: "\u25CB \u5186", }); const ACTION_LABELS = Object.freeze({ undo: "\u21B6 \u4E00\u3064\u623B\u3059", clear: "\uD83D\uDDD1 \u5168\u6D88\u3057", }); function templateSegments(name) { if (name === "bar") return [[-130, 0, 130, 0]]; if (name === "cross") return [[-118, 0, 118, 0], [0, -90, 0, 90]]; if (name === "circle") { const segments = []; const n = 18, r = 108; for (let i = 0; i < n; i += 1) { const a = i / n * Math.PI * 2; const b = (i + 1) / n * Math.PI * 2; segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]); } return segments; } return null; } function decorateToolbar(dialog, prefix = "rotator") { if (!dialog) return false; for (const btn of dialog.querySelectorAll(`[data-${prefix}-mode]`)) { const key = btn.dataset[`${prefix}Mode`]; if (MODE_LABELS[key]) { btn.textContent = MODE_LABELS[key]; btn.title = MODE_LABELS[key].replace(/^[^\s]+\s*/, ""); } } for (const btn of dialog.querySelectorAll(`[data-${prefix}-template]`)) { const key = btn.dataset[`${prefix}Template`]; if (TEMPLATE_LABELS[key]) { btn.textContent = TEMPLATE_LABELS[key]; btn.title = TEMPLATE_LABELS[key].replace(/^[^\s]+\s*/, ""); } } const undo = dialog.querySelector(`#${prefix}Undo`); const clear = dialog.querySelector(`#${prefix}Clear`); if (undo) undo.textContent = ACTION_LABELS.undo; if (clear) clear.textContent = ACTION_LABELS.clear; return true; } function pointSegmentDistance(p, seg) { if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity; return global.TarinaiGeometry.pointSegmentDistance(p.x, p.y, seg[0], seg[1], seg[2], seg[3]); } function canvasLocalPoint(canvas, e, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) { if (!canvas || !e) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); const sx = canvas.width / Math.max(1, rect.width); const sy = canvas.height / Math.max(1, rect.height); return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy }; } function cleanSegments(segments = [], opts = {}) { const limit = Math.max(1, Number(opts.limit || 96) || 96); const bound = Math.max(1, Number(opts.bound || 420) || 420); const minLength = Math.max(0, Number(opts.minLength || 4) || 4); const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0]; const out = []; for (const seg of segments || []) { if (!Array.isArray(seg) || seg.length < 4) continue; const x1 = Math.max(-bound, Math.min(bound, Number(seg[0]) || 0)); const y1 = Math.max(-bound, Math.min(bound, Number(seg[1]) || 0)); const x2 = Math.max(-bound, Math.min(bound, Number(seg[2]) || 0)); const y2 = Math.max(-bound, Math.min(bound, Number(seg[3]) || 0)); if (Math.hypot(x2 - x1, y2 - y1) >= minLength) out.push([x1, y1, x2, y2]); if (out.length >= limit) break; } if (!out.length) out.push(fallback.slice(0, 4)); return out; } function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) { if (!ctx || !canvas) return false; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.fillStyle = "#f7f3ea"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.translate(cx, cy); ctx.strokeStyle = "rgba(100,80,60,0.16)"; ctx.lineWidth = 1; for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); } for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); } ctx.strokeStyle = "rgba(70,60,50,0.38)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke(); return true; } function drawSegments(ctx, segments = [], opts = {}) { if (!ctx) return false; const lineWidth = Math.max(1, Number(opts.lineWidth || 12) || 12); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = opts.strokeStyle || "rgba(80,150,205,0.92)"; ctx.lineWidth = lineWidth; ctx.beginPath(); for (const seg of segments || []) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); } ctx.stroke(); if (opts.highlight !== false) { ctx.strokeStyle = opts.highlightStyle || "rgba(255,255,255,0.58)"; ctx.lineWidth = Math.max(1.3, lineWidth * 0.20); ctx.beginPath(); for (const seg of segments || []) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); } ctx.stroke(); } return true; } function eraseSegmentsAt(segments = [], point, radius) { const before = segments.length; const next = (segments || []).filter(seg => pointSegmentDistance(point, seg) > radius); return { segments: next, changed: next.length !== before }; } function syncInputPair(a, b, onChange = null) { if (!a || !b) return false; const sync = (from, to) => { to.value = from.value; if (typeof onChange === "function") onChange(); }; a.oninput = () => sync(a, b); b.oninput = () => sync(b, a); return true; } function drawDraftSegment(ctx, draft, lineWidth, opts = {}) { if (!ctx || !draft) return false; ctx.setLineDash(opts.dash || [7, 5]); ctx.strokeStyle = opts.strokeStyle || "rgba(60,130,210,0.85)"; ctx.lineWidth = Math.max(2, Number(lineWidth || 12) * 0.45); ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke(); ctx.setLineDash([]); return true; } function drawErasePreview(ctx, cx, cy, point, radius, opts = {}) { if (!ctx || !point) return false; ctx.save(); ctx.translate(cx || 0, cy || 0); ctx.globalAlpha = opts.alpha ?? 0.42; ctx.strokeStyle = opts.strokeStyle || "rgba(210,70,60,0.86)"; ctx.lineWidth = opts.lineWidth || 1.6; ctx.setLineDash(opts.dash || [4, 4]); ctx.beginPath(); ctx.arc(point.x, point.y, radius, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); return true; } function createSegmentEditorController(opts = {}) { const dialog = opts.dialog; const canvas = opts.canvas; const ctx = opts.ctx || canvas?.getContext?.("2d"); if (!dialog || !canvas || !ctx) return null; const prefix = opts.prefix || "rotator"; const cx = opts.cx ?? canvas.width / 2; const cy = opts.cy ?? canvas.height / 2; const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0]; let mode = "line"; let segments = (opts.segments || []).map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4); let draft = null; let drawing = false; const lineWidth = () => Math.max(1, Number(typeof opts.lineWidth === "function" ? opts.lineWidth() : opts.lineWidth || 12) || 12); const strokeStyle = () => typeof opts.strokeStyle === "function" ? opts.strokeStyle() : opts.strokeStyle; const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength }); }; const toLocal = (e) => canvasLocalPoint(canvas, e, cx, cy); const draw = () => { drawEditorBase(ctx, canvas, cx, cy); if (typeof opts.drawBeforeSegments === "function") opts.drawBeforeSegments(ctx, { cx, cy, lineWidth: lineWidth(), segments: segments.slice() }); const lw = lineWidth(); drawSegments(ctx, segments, { lineWidth: lw, strokeStyle: strokeStyle() || "rgba(80,150,205,0.92)" }); drawDraftSegment(ctx, draft, lw, opts.draft || {}); if (typeof opts.drawCenter === "function") opts.drawCenter(ctx, { cx, cy, lineWidth: lw, segments: segments.slice() }); ctx.restore(); return true; }; const setMode = (next) => { mode = next === "free" || next === "erase" ? next : "line"; for (const btn of dialog.querySelectorAll(`[data-${prefix}-mode]`)) btn.classList.toggle("active", btn.dataset[`${prefix}Mode`] === mode); canvas.style.cursor = mode === "erase" ? "cell" : "crosshair"; return mode; }; const eraseAt = (p) => { const radius = Math.max(1, typeof opts.eraseRadius === "function" ? opts.eraseRadius(lineWidth()) : Number(opts.eraseRadius || 24) || 24); const erased = eraseSegmentsAt(segments, p, radius); segments = erased.segments; if (erased.changed) { draft = null; draw(); return true; } draw(); drawErasePreview(ctx, cx, cy, p, radius, opts.erasePreview || {}); return false; }; const setTemplate = (name) => { const shared = templateSegments(name); if (shared) segments = shared.map(seg => seg.slice(0, 4)); clean(); draw(); }; dialog.querySelectorAll(`[data-${prefix}-mode]`).forEach(btn => { btn.onclick = () => setMode(btn.dataset[`${prefix}Mode`]); }); dialog.querySelectorAll(`[data-${prefix}-template]`).forEach(btn => { btn.onclick = () => setTemplate(btn.dataset[`${prefix}Template`]); }); canvas.onpointerdown = (e) => { canvas.setPointerCapture?.(e.pointerId); const p = toLocal(e); drawing = true; if (mode === "erase") { draft = null; eraseAt(p); return; } draft = [p.x, p.y, p.x, p.y]; if (mode === "free") segments.push([p.x, p.y, p.x, p.y]); draw(); }; canvas.onpointermove = (e) => { if (!drawing) return; const p = toLocal(e); if (mode === "erase") { eraseAt(p); return; } if (!draft) return; if (mode === "free") { const last = segments[segments.length - 1]; if (!last) return; const lx = last[2], ly = last[3]; if (Math.hypot(p.x - lx, p.y - ly) >= 5) { last[2] = p.x; last[3] = p.y; segments.push([p.x, p.y, p.x, p.y]); } } else { draft[2] = p.x; draft[3] = p.y; } draw(); }; canvas.onpointerup = (e) => { if (!drawing) return; drawing = false; const p = toLocal(e); if (mode === "erase") { draft = null; draw(); return; } if (!draft) return; if (mode === "line") { const seg = [draft[0], draft[1], p.x, p.y]; if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg); } draft = null; clean(); draw(); }; const undo = dialog.querySelector(`#${prefix}Undo`); const clear = dialog.querySelector(`#${prefix}Clear`); if (undo) undo.onclick = () => { segments.pop(); clean(); draw(); }; if (clear) clear.onclick = () => { segments = []; draft = null; draw(); }; const close = () => { canvas.style.cursor = ""; canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null; }; setMode(opts.initialMode || "line"); return { cleanSegments: clean, close, draw, getSegments: () => segments.map(seg => seg.slice(0, 4)), setMode, setSegments(next = []) { segments = next.map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4); draft = null; draw(); }, }; } global.TarinaiPhysicsShapeEditorSystem = Object.freeze({ decorateToolbar, templateSegments, pointSegmentDistance, canvasLocalPoint, cleanSegments, drawEditorBase, drawSegments, eraseSegmentsAt, syncInputPair, drawDraftSegment, drawErasePreview, createSegmentEditorController, }); })(typeof window !== "undefined" ? window : globalThis);