新增 tip_above_rack_length 参数并更新 PRCXI 枪头尺寸

- TipInfo 新增 tip_above_rack_length 可选字段
- 编辑器支持 tip_above 与 dz 互算,更新中文标签
- 侧视图绘制枪头露出部分并标注,俯视图/侧视图增加 dx/dy/dz 标注
- 预览增加回中按钮,详情页展示新字段
- 导入时自动计算 tip_above_rack_length
- 批量更新 PRCXI 枪头物理尺寸及 registry YAML

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ALITTLELZ
2026-04-01 18:22:23 +08:00
parent 2fd4270831
commit 59aa991988
13 changed files with 871 additions and 759 deletions

View File

@@ -107,6 +107,7 @@ function collectFormData() {
tip_volume: g('f-tip_vol') || 300,
tip_length: g('f-tip_len') || 60,
tip_fitting_depth: g('f-tip_dep') || 51,
tip_above_rack_length: g('f-tip_above'),
has_filter: g('f-tip_filter') || false,
};
}
@@ -155,6 +156,60 @@ document.addEventListener('DOMContentLoaded', () => {
if (!form) return;
form.addEventListener('input', updatePreview);
form.addEventListener('change', updatePreview);
// tip_above_rack_length 与 dz 互算
// 公式: tip_length = tip_above_rack_length + size_z - dz
// 规则: 填 tip_above → 自动算 dz填 dz → 自动算 tip_above
// 改 size_z / tip_length → 优先重算 tip_above若有值否则算 dz
function _getVal(id) {
const el = document.getElementById(id);
return (el && el.value !== '') ? parseFloat(el.value) : null;
}
function _setVal(id, v) {
const el = document.getElementById(id);
if (el) el.value = Math.round(v * 1000) / 1000;
}
function autoCalcTipAbove(changedId) {
const typeEl = document.getElementById('f-type');
if (!typeEl || typeEl.value !== 'tip_rack') return;
const tipLen = _getVal('f-tip_len');
const sizeZ = _getVal('f-size_z');
const dz = _getVal('f-grid_dz');
const above = _getVal('f-tip_above');
// 需要 tip_length 和 size_z 才能计算
if (tipLen == null || sizeZ == null) return;
if (changedId === 'f-tip_above') {
// 用户填了 tip_above → 算 dz
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
} else if (changedId === 'f-grid_dz') {
// 用户填了 dz → 算 tip_above
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
} else {
// size_z 或 tip_length 变了 → 优先重算 tip_above若已有值或 dz 已有值)
if (dz != null) {
_setVal('f-tip_above', tipLen - sizeZ + dz);
} else if (above != null) {
_setVal('f-grid_dz', above + sizeZ - tipLen);
}
}
}
// 绑定 input 事件
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
}
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
const typeEl = document.getElementById('f-type');
if (typeEl && typeEl.value === 'tip_rack') {
autoCalcTipAbove('f-grid_dz');
}
});
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy

View File

@@ -161,6 +161,27 @@ function renderTopDown(container, data) {
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
}
}
// dx / dy 标注(板边到首个子元素左上角)
const dimColor = '#e67e22';
const firstLeft = pad + dx; // 首列子元素左边 X
const firstTop = pad + dy; // 首行子元素上边 Y
if (dx > 0.1) {
// dx: 板左边 → 首列子元素左边,画在第一行子元素中心高度
const annY = firstTop + csy / 2;
_line(svg, pad, annY, firstLeft, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, firstLeft, annY - 2, firstLeft, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
if (dy > 0.1) {
// dy: 板上边 → 首行子元素上边,画在第一列子元素中心宽度
const annX = firstLeft + csx / 2;
_line(svg, annX, pad, annX, firstTop, dimColor, '1,1');
_line(svg, annX - 2, pad, annX + 2, pad, dimColor);
_line(svg, annX - 2, firstTop, annX + 2, firstTop, dimColor);
_text(svg, annX + 4, pad + dy / 2 + 1, `dy=${dy}`, '2.5', 'start', dimColor);
}
} else if (data.type === 'plate_adapter' && data.adapter) {
// 绘制适配器凹槽
const adp = data.adapter;
@@ -190,17 +211,33 @@ function renderSideProfile(container, data) {
// 按比例缩放,侧面以 X-Z 面
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
// 计算枪头露出高度(仅 tip_rack
const tip = data.tip;
const grid = data.grid;
let tipAbove = 0;
if (data.type === 'tip_rack' && tip) {
if (tip.tip_above_rack_length != null && tip.tip_above_rack_length > 0) {
tipAbove = tip.tip_above_rack_length;
} else if (tip.tip_length && grid) {
const dz = grid.dz || 0;
const calc = tip.tip_length - (sz - dz);
if (calc > 0) tipAbove = calc;
}
}
const drawW = sx;
const drawH = sz;
const w = drawW + pad * 2 + 30; // 额外空间给标注
const h = drawH + pad * 2 + 10;
const h = drawH + tipAbove + pad * 2 + 10;
const svg = _makeSVG(w, h);
const color = TYPE_COLORS[data.type] || '#3b82f6';
const baseY = pad + drawH; // 底部 Y
const baseY = pad + tipAbove + drawH; // 底部 Y
const rackTopY = pad + tipAbove; // rack 顶部 Y
// 板壳矩形
_rect(svg, pad, pad, drawW, drawH, color + '15', color);
_rect(svg, pad, rackTopY, drawW, drawH, color + '15', color);
// 尺寸标注
// X 方向
@@ -208,21 +245,19 @@ function renderSideProfile(container, data) {
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
// Z 方向
_line(svg, pad + drawW + 5, pad, pad + drawW + 5, baseY, '#333');
_line(svg, pad + drawW + 5, rackTopY, pad + drawW + 5, baseY, '#333');
const zt = document.createElementNS(_svgNS(), 'text');
zt.setAttribute('x', pad + drawW + 12);
zt.setAttribute('y', pad + drawH / 2);
zt.setAttribute('y', rackTopY + drawH / 2);
zt.setAttribute('font-size', '3.5');
zt.setAttribute('text-anchor', 'middle');
zt.setAttribute('fill', '#333');
zt.setAttribute('font-family', 'sans-serif');
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${pad + drawH / 2})`);
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${rackTopY + drawH / 2})`);
zt.textContent = `${sz} mm`;
svg.appendChild(zt);
const grid = data.grid;
const well = data.well;
const tip = data.tip;
const tube = data.tube;
if (grid && (well || tip || tube)) {
@@ -230,6 +265,7 @@ function renderSideProfile(container, data) {
const dz = grid.dz || 0;
const idx = grid.item_dx || 9;
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
const dimColor = '#e67e22';
const child = well || tube;
const childTip = tip;
@@ -275,31 +311,71 @@ function renderSideProfile(container, data) {
// dz 标注
if (dz > 0) {
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
_line(svg, lx, baseY, lx, baseY - dz, '#999', '1,1');
_text(svg, lx + 6, baseY - dz / 2, `dz=${dz}`, '2.5', 'start', '#999');
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
}
// dx 标注
if (dx > 0.1) {
const annY = rackTopY + 4;
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
}
if (childTip) {
// 枪头截面
const tipLen = childTip.tip_length || 50;
const nDraw = Math.min(nx, 12);
for (let i = 0; i < nDraw; i++) {
const cx = pad + dx + 3.5 + i * idx;
const topZ = pad + dz;
const tipLen = childTip.tip_length || 50;
const drawLen = Math.min(tipLen, sz - dz);
// 枪头顶部 = rack顶部 - 露出长度
const tipTopZ = rackTopY - tipAbove;
const drawLen = Math.min(tipLen, sz - dz + tipAbove);
// 枪头轮廓 (梯形)
const topW = 4;
const botW = 1.5;
const p = document.createElementNS(_svgNS(), 'polygon');
p.setAttribute('points',
`${cx - topW / 2},${topZ} ${cx + topW / 2},${topZ} ${cx + botW / 2},${topZ + drawLen} ${cx - botW / 2},${topZ + drawLen}`);
`${cx - topW / 2},${tipTopZ} ${cx + topW / 2},${tipTopZ} ${cx + botW / 2},${tipTopZ + drawLen} ${cx - botW / 2},${tipTopZ + drawLen}`);
p.setAttribute('fill', '#10b98133');
p.setAttribute('stroke', '#10b981');
p.setAttribute('stroke-width', '0.3');
svg.appendChild(p);
}
// dz 标注
if (dz > 0) {
const lx = pad + dx + nDraw * idx + 5;
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
}
// dx 标注
if (dx > 0.1) {
const annY = rackTopY + 4;
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
// 露出长度标注线
if (tipAbove > 0) {
const annotX = pad + dx + nDraw * idx + 8;
// rack 顶部水平参考线
_line(svg, annotX - 3, rackTopY, annotX + 3, rackTopY, '#10b981');
// 枪头顶部水平参考线
_line(svg, annotX - 3, rackTopY - tipAbove, annotX + 3, rackTopY - tipAbove, '#10b981');
// 竖直标注线
_line(svg, annotX, rackTopY - tipAbove, annotX, rackTopY, '#10b981', '1,1');
_text(svg, annotX + 2, rackTopY - tipAbove / 2 + 1, `露出=${Math.round(tipAbove * 100) / 100}mm`, '2.5', 'start', '#10b981');
}
}
} else if (data.type === 'plate_adapter' && data.adapter) {
const adp = data.adapter;
@@ -309,10 +385,10 @@ function renderSideProfile(container, data) {
const ahx = adp.adapter_hole_size_x || 127;
// 凹槽截面
_rect(svg, pad + adx_val, pad + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
_text(svg, pad + adx_val + ahx / 2, pad + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
_rect(svg, pad + adx_val, rackTopY + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
_text(svg, pad + adx_val + ahx / 2, rackTopY + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
} else if (data.type === 'trash') {
_text(svg, pad + drawW / 2, pad + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
_text(svg, pad + drawW / 2, rackTopY + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
}
container.appendChild(svg);
@@ -323,13 +399,21 @@ function renderSideProfile(container, data) {
function _enableZoomPan(svgEl, origViewBox) {
const parts = origViewBox.split(' ').map(Number);
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
const origW = vw, origH = vh;
const origVx = vx, origVy = vy, origW = vw, origH = vh;
const MIN_SCALE = 0.5, MAX_SCALE = 5;
function applyViewBox() {
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
}
function resetView() {
vx = origVx; vy = origVy; vw = origW; vh = origH;
applyViewBox();
}
// 将 resetView 挂到 svg 元素上,方便外部调用
svgEl._resetView = resetView;
svgEl.addEventListener('wheel', function (e) {
e.preventDefault();
if (e.ctrlKey) {
@@ -356,3 +440,11 @@ function _enableZoomPan(svgEl, origViewBox) {
applyViewBox();
}, { passive: false });
}
// 回中按钮:重置指定容器内 SVG 的 viewBox
function resetSvgView(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const svg = container.querySelector('svg');
if (svg && svg._resetView) svg._resetView();
}