新增 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

@@ -399,6 +399,11 @@ def import_from_code() -> LabwareDB:
tip_fitting_depth=tip_depth,
has_filter=tip_filter,
)
# 计算 tip_above_rack_length = tip_length - (size_z - dz)
if grid_data:
_dz = grid_data.get("dz", 0.0)
_above = tip_length - (item.size_z - _dz)
item.tip.tip_above_rack_length = round(_above, 4) if _above > 0 else None
elif type_name == "tube_rack" and children:
if grid_data:

View File

@@ -306,9 +306,9 @@
"type": "tip_rack",
"function_name": "PRCXI_10ul_eTips",
"docstring": "对应 JSON Code: ZX-001-10+",
"size_x": 122.11,
"size_x": 127.76,
"size_y": 85.48,
"size_z": 58.23,
"size_z": 58.0,
"model": "PRCXI_10ul_eTips",
"category": "tip_rack",
"plate_type": null,
@@ -327,21 +327,22 @@
"grid": {
"num_items_x": 12,
"num_items_y": 8,
"dx": 7.97,
"dy": 5.0,
"dz": 2.0,
"dx": 10.63,
"dy": 7.49,
"dz": 14.6,
"item_dx": 9.0,
"item_dy": 9.0
},
"well": null,
"volume_functions": null,
"tip": {
"spot_size_x": 7.0,
"spot_size_y": 7.0,
"spot_size_x": 7.5,
"spot_size_y": 7.5,
"spot_size_z": 0.0,
"tip_volume": 300.0,
"tip_length": 60.0,
"tip_fitting_depth": 51.0,
"tip_volume": 10.0,
"tip_length": 52.0,
"tip_fitting_depth": 8.2,
"tip_above_rack_length": 8.6,
"has_filter": false
},
"tube": null,
@@ -354,9 +355,9 @@
"type": "tip_rack",
"function_name": "PRCXI_300ul_Tips",
"docstring": "对应 JSON Code: ZX-001-300\n吸头盒通常比较特殊需要定义 Tip 对象",
"size_x": 122.11,
"size_x": 127.76,
"size_y": 85.48,
"size_z": 58.23,
"size_z": 58.0,
"model": "PRCXI_300ul_Tips",
"category": "tip_rack",
"plate_type": null,
@@ -375,21 +376,22 @@
"grid": {
"num_items_x": 12,
"num_items_y": 8,
"dx": 7.97,
"dy": 5.0,
"dz": 2.0,
"dx": 10.63,
"dy": 7.49,
"dz": 6.6,
"item_dx": 9.0,
"item_dy": 9.0
},
"well": null,
"volume_functions": null,
"tip": {
"spot_size_x": 7.0,
"spot_size_y": 7.0,
"spot_size_x": 7.5,
"spot_size_y": 7.5,
"spot_size_z": 0.0,
"tip_volume": 300.0,
"tip_length": 60.0,
"tip_fitting_depth": 51.0,
"tip_fitting_depth": 8.2,
"tip_above_rack_length": 8.6,
"has_filter": false
},
"tube": null,
@@ -788,9 +790,9 @@
"type": "tip_rack",
"function_name": "PRCXI_1250uL_Tips",
"docstring": "Code: ZX-001-1250",
"size_x": 118.09,
"size_y": 80.7,
"size_z": 107.67,
"size_x": 127.76,
"size_y": 85.48,
"size_z": 98.0,
"model": "PRCXI_1250uL_Tips",
"category": "tip_rack",
"plate_type": null,
@@ -809,21 +811,22 @@
"grid": {
"num_items_x": 12,
"num_items_y": 8,
"dx": 5.57,
"dy": 4.875,
"dz": 2.0,
"dx": 10.63,
"dy": 7.49,
"dz": 6.6,
"item_dx": 9.0,
"item_dy": 9.0
},
"well": null,
"volume_functions": null,
"tip": {
"spot_size_x": 7.0,
"spot_size_y": 7.0,
"spot_size_z": 0.0,
"tip_volume": 300.0,
"tip_length": 60.0,
"tip_fitting_depth": 51.0,
"spot_size_x": 7.5,
"spot_size_y": 7.5,
"spot_size_z": 100.0,
"tip_volume": 1250.0,
"tip_length": 100.0,
"tip_fitting_depth": 8.2,
"tip_above_rack_length": 8.6,
"has_filter": false
},
"tube": null,
@@ -836,9 +839,9 @@
"type": "tip_rack",
"function_name": "PRCXI_10uL_Tips",
"docstring": "Code: ZX-001-10",
"size_x": 120.98,
"size_y": 82.12,
"size_z": 67.0,
"size_x": 127.76,
"size_y": 85.48,
"size_z": 58.0,
"model": "PRCXI_10uL_Tips",
"category": "tip_rack",
"plate_type": null,
@@ -857,21 +860,22 @@
"grid": {
"num_items_x": 12,
"num_items_y": 8,
"dx": 8.49,
"dy": 7.06,
"dz": 2.0,
"dx": 10.63,
"dy": 7.49,
"dz": 14.6,
"item_dx": 9.0,
"item_dy": 9.0
},
"well": null,
"volume_functions": null,
"tip": {
"spot_size_x": 7.0,
"spot_size_y": 7.0,
"spot_size_x": 7.5,
"spot_size_y": 7.5,
"spot_size_z": 0.0,
"tip_volume": 300.0,
"tip_length": 60.0,
"tip_fitting_depth": 51.0,
"tip_volume": 10.0,
"tip_length": 52.0,
"tip_fitting_depth": 8.2,
"tip_above_rack_length": 8.6,
"has_filter": false
},
"tube": null,
@@ -884,8 +888,8 @@
"type": "tip_rack",
"function_name": "PRCXI_1000uL_Tips",
"docstring": "Code: ZX-001-1000",
"size_x": 128.09,
"size_y": 85.8,
"size_x": 127.76,
"size_y": 85.48,
"size_z": 98.0,
"model": "PRCXI_1000uL_Tips",
"category": "tip_rack",
@@ -905,21 +909,22 @@
"grid": {
"num_items_x": 12,
"num_items_y": 8,
"dx": 10.525,
"dy": 7.425,
"dz": 2.0,
"dx": 10.63,
"dy": 7.49,
"dz": 6.6,
"item_dx": 9.0,
"item_dy": 9.0
},
"well": null,
"volume_functions": null,
"tip": {
"spot_size_x": 7.0,
"spot_size_y": 7.0,
"spot_size_z": 0.0,
"tip_volume": 300.0,
"tip_length": 60.0,
"tip_fitting_depth": 51.0,
"spot_size_x": 7.5,
"spot_size_y": 7.5,
"spot_size_z": 100.0,
"tip_volume": 1000.0,
"tip_length": 100.0,
"tip_fitting_depth": 8.2,
"tip_above_rack_length": 8.6,
"has_filter": false
},
"tube": null,
@@ -968,6 +973,7 @@
"tip_volume": 300.0,
"tip_length": 60.0,
"tip_fitting_depth": 51.0,
"tip_above_rack_length": null,
"has_filter": false
},
"tube": null,
@@ -1256,6 +1262,55 @@
},
"include_in_template_matching": false,
"template_kind": null
},
{
"id": "5bb65eb2",
"type": "tip_rack",
"function_name": "PRCXI_50uL_tips",
"docstring": "",
"size_x": 127.76,
"size_y": 85.48,
"size_z": 58.0,
"model": "PRCXI_50uL_tips",
"category": null,
"plate_type": null,
"material_info": {
"uuid": "",
"Code": "",
"Name": "",
"materialEnum": null,
"SupplyType": 1
},
"registry_category": [
"prcxi",
"tip_racks"
],
"registry_description": "",
"grid": {
"num_items_x": 12,
"num_items_y": 8,
"dx": 10.63,
"dy": 7.49,
"dz": 13.6,
"item_dx": 9.0,
"item_dy": 9.0
},
"well": null,
"volume_functions": null,
"tip": {
"spot_size_x": 7.5,
"spot_size_y": 7.5,
"spot_size_z": 0.0,
"tip_volume": 50.0,
"tip_length": 53.0,
"tip_fitting_depth": 8.2,
"tip_above_rack_length": 8.6,
"has_filter": false
},
"tube": null,
"adapter": null,
"include_in_template_matching": false,
"template_kind": "tip_rack"
}
]
}

View File

@@ -53,6 +53,7 @@ class TipInfo(BaseModel):
tip_volume: float = 300.0
tip_length: float = 60.0
tip_fitting_depth: float = 51.0
tip_above_rack_length: Optional[float] = None
has_filter: bool = False

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();
}

View File

@@ -85,7 +85,10 @@
<tr><td class="label">Spot 尺寸</td><td>{{ item.tip.spot_size_x }} x {{ item.tip.spot_size_y }} x {{ item.tip.spot_size_z }}</td></tr>
<tr><td class="label">容量</td><td>{{ item.tip.tip_volume }} uL</td></tr>
<tr><td class="label">长度</td><td>{{ item.tip.tip_length }} mm</td></tr>
<tr><td class="label">配合深度</td><td>{{ item.tip.tip_fitting_depth }} mm</td></tr>
<tr><td class="label">取枪头插入深度</td><td>{{ item.tip.tip_fitting_depth }} mm</td></tr>
{% if item.tip.tip_above_rack_length is not none %}
<tr><td class="label">枪头露出枪头盒长度</td><td>{{ item.tip.tip_above_rack_length }} mm</td></tr>
{% endif %}
<tr><td class="label">有滤芯</td><td>{{ item.tip.has_filter }}</td></tr>
</table>
</div>
@@ -127,11 +130,17 @@
<!-- 右侧: 可视化 -->
<div class="detail-viz">
<div class="viz-card">
<h3>俯视图 (Top-Down)</h3>
<h3 style="display:flex;align-items:center;justify-content:space-between;">
俯视图 (Top-Down)
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
</h3>
<div id="svg-topdown"></div>
</div>
<div class="viz-card">
<h3>侧面截面图 (Side Profile)</h3>
<h3 style="display:flex;align-items:center;justify-content:space-between;">
侧面截面图 (Side Profile)
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
</h3>
<div id="svg-side"></div>
</div>
</div>

View File

@@ -147,8 +147,15 @@
</div>
<div class="form-row-3">
<div><label>tip_volume <span class="label-cn">枪头容量 (uL)</span></label><input type="number" step="any" name="tip_vol" id="f-tip_vol" value="{{ item.tip.tip_volume if item and item.tip else 300 }}"></div>
<div><label>tip_length <span class="label-cn">枪头长度 (mm)</span></label><input type="number" step="any" name="tip_len" id="f-tip_len" value="{{ item.tip.tip_length if item and item.tip else 60 }}"></div>
<div><label>fitting_depth <span class="label-cn">配合深度</span></label><input type="number" step="any" name="tip_dep" id="f-tip_dep" value="{{ item.tip.tip_fitting_depth if item and item.tip else 51 }}"></div>
<div><label>tip_length <span class="label-cn">枪头长度 (mm)</span></label><input type="number" step="any" name="tip_len" id="f-tip_len" value="{{ item.tip.tip_length if item and item.tip else 60 }}"></div>
<div><label>fitting_depth <span class="label-cn">取枪头时插入的长度 (mm)</span></label><input type="number" step="any" name="tip_dep" id="f-tip_dep" value="{{ item.tip.tip_fitting_depth if item and item.tip else 51 }}"></div>
</div>
<div class="form-row">
<label>tip_above_rack_length <span class="label-cn">枪头在枪头盒上方的部分的长度 (mm)</span></label>
<input type="number" step="any" name="tip_above" id="f-tip_above"
value="{{ item.tip.tip_above_rack_length if item and item.tip and item.tip.tip_above_rack_length is not none else '' }}"
placeholder="tip_length - (size_z - dz)">
<small style="color:#888;margin-top:2px;">公式: tip_length = tip_above + size_z - dz填 tip_above 自动算 dz填 dz 自动算 tip_above</small>
</div>
<div class="form-row">
<label><input type="checkbox" name="tip_filter" id="f-tip_filter" {% if item and item.tip and item.tip.has_filter %}checked{% endif %}> has_filter</label>
@@ -219,11 +226,17 @@
<!-- 右侧: 实时预览 -->
<div class="edit-preview">
<div class="viz-card">
<h3>预览: 俯视图</h3>
<h3 style="display:flex;align-items:center;justify-content:space-between;">
预览: 俯视图
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
</h3>
<div id="svg-topdown"></div>
</div>
<div class="viz-card">
<h3>预览: 侧面截面图</h3>
<h3 style="display:flex;align-items:center;justify-content:space-between;">
预览: 侧面截面图
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
</h3>
<div id="svg-side"></div>
</div>
</div>