Refactor: extract shared code, add constants, clean up dispatch

- Extract parseConveyanceLabel to shared label-utils.ts (was duplicated)
- Add EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE to symbol-config.ts
- Replace all hardcoded fill/stroke/lineWidth with CONVEYANCE_STYLE
- Replace magic font numbers (14, 4, 0.5) with LABEL_CONFIG constants
- Extract drawSpurSymbol, drawExtendoSymbol, drawRectConveyanceSymbol
  from inline code — drawSymbolBody is now a clean dispatch
- Convert getIgnitionTagPath from 18 if-statements to data-driven table
- Add THEME.marquee for selection rectangle colors
- Remove no-op assignment in parseConveyanceLabel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
igurielidze 2026-03-31 22:58:36 +04:00
parent 8a52449dfe
commit 3f122d9177
6 changed files with 214 additions and 167 deletions

View File

@ -62,6 +62,12 @@ export const THEME = {
strokeColor: '#000000',
lineWidth: 1,
},
marquee: {
strokeColor: '#4a9eff',
lineWidth: 1,
dash: [4, 3] as readonly number[],
fillColor: 'rgba(74, 158, 255, 0.1)',
},
canvas: {
maxRenderScale: 4,
},

View File

@ -1,8 +1,9 @@
import { layout } from '../stores/layout.svelte.js';
import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, isRectConveyanceType, isExtendoType, getCurveGeometry, getSymbolGroup, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js';
import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, isRectConveyanceType, isExtendoType, getCurveGeometry, getSymbolGroup, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG, EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE } from '../symbols.js';
import { checkSpacingViolation } from './collision.js';
import { marqueeRect } from './interactions.js';
import { THEME } from './render-theme.js';
import { parseConveyanceLabel } from '../label-utils.js';
import type { PlacedSymbol } from '../types.js';
let ctx: CanvasRenderingContext2D | null = null;
@ -82,10 +83,10 @@ export function render() {
// Marquee selection rectangle
if (marqueeRect) {
ctx.strokeStyle = '#4a9eff';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.fillStyle = 'rgba(74, 158, 255, 0.1)';
ctx.strokeStyle = THEME.marquee.strokeColor;
ctx.lineWidth = THEME.marquee.lineWidth;
ctx.setLineDash(THEME.marquee.dash as number[]);
ctx.fillStyle = THEME.marquee.fillColor;
ctx.fillRect(marqueeRect.x, marqueeRect.y, marqueeRect.w, marqueeRect.h);
ctx.strokeRect(marqueeRect.x, marqueeRect.y, marqueeRect.w, marqueeRect.h);
ctx.setLineDash([]);
@ -436,9 +437,9 @@ function drawPhotoeyeSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.lineTo(x + w - rightCap, beamTop);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fillStyle = CONVEYANCE_STYLE.fillColor;
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
ctx.fill();
ctx.stroke();
}
@ -454,13 +455,64 @@ function drawCurvedSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.arc(arcCx, arcCy, innerR, -sweepRad, 0, false);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fillStyle = CONVEYANCE_STYLE.fillColor;
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
ctx.fill();
ctx.stroke();
}
/** Draw spur trapezoid programmatically so w and w2 are respected during resize */
function drawSpurSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const w2 = sym.w2 ?? sym.w;
ctx.beginPath();
ctx.moveTo(sym.x, sym.y);
ctx.lineTo(sym.x + w2, sym.y);
ctx.lineTo(sym.x + sym.w, sym.y + sym.h);
ctx.lineTo(sym.x, sym.y + sym.h);
ctx.closePath();
ctx.fillStyle = CONVEYANCE_STYLE.fillColor;
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
ctx.fill();
ctx.stroke();
}
/** Draw extendo: fixed left bracket + stretchy right belt */
function drawExtendoSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const bracketW = EXTENDO_CONFIG.bracketWidthRatio * EXTENDO_CONFIG.defaultWidth;
const p = EXTENDO_CONFIG.points;
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
ctx.beginPath();
ctx.moveTo(x + bracketW * p.tabTopRight.x, y + h * p.tabTopRight.y);
ctx.lineTo(x + bracketW * p.bracketTopRight.x, y + h * p.bracketTopRight.y);
ctx.lineTo(x + bracketW * p.beltTop.x, y + h * p.beltTop.y);
ctx.lineTo(x + w, y + h * p.beltTop.y);
ctx.lineTo(x + w, y + h * p.beltBottom.y);
ctx.lineTo(x + bracketW * p.beltBottom.x, y + h * p.beltBottom.y);
ctx.lineTo(x + bracketW * p.bracketBottomRight.x, y + h * p.bracketBottomRight.y);
ctx.lineTo(x + bracketW * p.bracketBottomLeft.x, y + h * p.bracketBottomLeft.y);
ctx.lineTo(x + bracketW * p.notchBottom.x, y + h * p.notchBottom.y);
ctx.lineTo(x, y + h * p.farLeftBottom.y);
ctx.lineTo(x, y + h * p.farLeftTop.y);
ctx.lineTo(x + bracketW * p.tabTopLeft.x, y + h * p.tabTopLeft.y);
ctx.closePath();
ctx.fillStyle = CONVEYANCE_STYLE.fillColor;
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
ctx.fill();
ctx.stroke();
}
/** Draw rectangular conveyance (conveyor, chute, etc.) */
function drawRectConveyanceSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.fillStyle = CONVEYANCE_STYLE.fillColor;
ctx.strokeStyle = CONVEYANCE_STYLE.strokeColor;
ctx.lineWidth = CONVEYANCE_STYLE.lineWidth;
ctx.fillRect(sym.x, sym.y, sym.w, sym.h);
ctx.strokeRect(sym.x, sym.y, sym.w, sym.h);
}
function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boolean {
if (isEpcType(sym.symbolId)) {
drawEpcSymbol(ctx, sym);
@ -469,49 +521,11 @@ function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boole
} else if (isCurvedType(sym.symbolId)) {
drawCurvedSymbol(ctx, sym);
} else if (isSpurType(sym.symbolId)) {
// Draw trapezoid programmatically so w and w2 are respected during resize
const w2 = sym.w2 ?? sym.w;
ctx.beginPath();
ctx.moveTo(sym.x, sym.y);
ctx.lineTo(sym.x + w2, sym.y);
ctx.lineTo(sym.x + sym.w, sym.y + sym.h);
ctx.lineTo(sym.x, sym.y + sym.h);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
drawSpurSymbol(ctx, sym);
} else if (isExtendoType(sym.symbolId)) {
// Extendo: fixed left bracket + stretchy right belt
// Y fractions from original SVG path, X uses fixed left bracket width
const bracketW = 10.6 / 31.07 * 73; // ~24.9 px at default 73w — fixed portion
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
ctx.beginPath();
ctx.moveTo(x + bracketW * 0.44, y + h * 0.085); // tab top-right
ctx.lineTo(x + bracketW, y + h * 0.085); // bracket top-right
ctx.lineTo(x + bracketW, y + h * 0.222); // step down to belt top
ctx.lineTo(x + w, y + h * 0.222); // belt top-right
ctx.lineTo(x + w, y + h * 0.780); // belt bottom-right
ctx.lineTo(x + bracketW, y + h * 0.780); // belt bottom-left
ctx.lineTo(x + bracketW, y + h * 0.917); // step down bracket bottom
ctx.lineTo(x + bracketW * 0.44, y + h * 0.916); // bracket bottom-left
ctx.lineTo(x + bracketW * 0.34, y + h * 0.985); // notch bottom
ctx.lineTo(x, y + h * 0.980); // far left bottom
ctx.lineTo(x, y + h * 0.017); // far left top
ctx.lineTo(x + bracketW * 0.34, y + h * 0.016); // tab top-left
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
drawExtendoSymbol(ctx, sym);
} else if (isRectConveyanceType(sym.symbolId)) {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.fillRect(sym.x, sym.y, sym.w, sym.h);
ctx.strokeRect(sym.x, sym.y, sym.w, sym.h);
drawRectConveyanceSymbol(ctx, sym);
} else if (isPhotoeyeType(sym.symbolId)) {
drawPhotoeyeSymbol(ctx, sym);
} else {
@ -522,27 +536,6 @@ function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boole
return true;
}
/** Parse a conveyance label like "UL17_22_VFD" into display lines.
* Strips known suffixes (_VFD, _VFD1, etc.), splits prefix from numbers,
* replaces underscores with dashes in the number part. */
function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } {
// Strip known device suffixes
let core = label.replace(/_?VFD\d*$/i, '');
if (core === label) core = label; // no suffix found, use as-is
// Split into letter prefix and rest
const m = core.match(/^([A-Za-z]+)(.*)$/);
if (m) {
const prefix = m[1];
const nums = m[2].replace(/_/g, '-').replace(/^-/, '');
if (nums) {
return { lines: [prefix, nums], stripped: [nums] };
}
return { lines: [prefix], stripped: [prefix] };
}
return { lines: [core], stripped: [core] };
}
/** Draw label inside a conveyance symbol — black bold text, auto-sized to fit */
function drawConveyanceLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
if (!sym.label) return;
@ -550,16 +543,16 @@ function drawConveyanceLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
if (isCurvedType(sym.symbolId)) { drawCurvedLabel(ctx, sym); return; }
const { lines, stripped } = parseConveyanceLabel(sym.label);
const pad = 2;
const pad = LABEL_CONFIG.padding;
// For spurs, center in the trapezoid shape, not bounding box
let cx: number, cy: number, availW: number, availH: number;
if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w;
// Compute text dimensions at 14px to find optimal placement
ctx.font = 'bold 14px Arial';
// Compute text dimensions at default fontSize to find optimal placement
ctx.font = LABEL_CONFIG.font;
const maxTextW = Math.max(...lines.map(l => ctx.measureText(l).width));
const th = 14 * lines.length;
const th = LABEL_CONFIG.fontSize * lines.length;
// Push text down where trapezoid is wider, but leave room
const optCy = Math.min(sym.h - th / 2 - 1, sym.h * 0.55);
// Right edge at top of text — text must not exceed this
@ -585,27 +578,27 @@ function drawConveyanceLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const needsMirrorFix = !!sym.mirrored;
const hasCorrection = needsFlip || needsMirrorFix;
ctx.fillStyle = '#000000';
ctx.fillStyle = LABEL_CONFIG.color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const tryLines of [lines, stripped]) {
let fontSize = 14;
let fontSize = LABEL_CONFIG.fontSize;
const lineCount = tryLines.length;
const totalTextH = () => fontSize * lineCount + (lineCount - 1) * 1;
while (totalTextH() > availH && fontSize > 4) fontSize -= 0.5;
while (totalTextH() > availH && fontSize > LABEL_CONFIG.minFontSize) fontSize -= LABEL_CONFIG.fontSizeStep;
ctx.font = `bold ${fontSize}px Arial`;
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
while (maxW > availW && fontSize > 4) {
fontSize -= 0.5;
ctx.font = `bold ${fontSize}px Arial`;
while (maxW > availW && fontSize > LABEL_CONFIG.minFontSize) {
fontSize -= LABEL_CONFIG.fontSizeStep;
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
}
if (fontSize >= 4) {
ctx.font = `bold ${fontSize}px Arial`;
if (fontSize >= LABEL_CONFIG.minFontSize) {
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
const lineH = fontSize;
if (hasCorrection) {
@ -659,24 +652,24 @@ function drawCurvedLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.save();
ctx.translate(textX, textY);
ctx.rotate(textRot);
ctx.fillStyle = '#000000';
ctx.fillStyle = LABEL_CONFIG.color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const tryLines of [lines, stripped]) {
let fontSize = 14;
let fontSize = LABEL_CONFIG.fontSize;
const lineCount = tryLines.length;
while (fontSize * lineCount > availW && fontSize > 4) fontSize -= 0.5;
ctx.font = `bold ${fontSize}px Arial`;
while (fontSize * lineCount > availW && fontSize > LABEL_CONFIG.minFontSize) fontSize -= LABEL_CONFIG.fontSizeStep;
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
// Available length along the arc at midR
const arcLen = midR * (angle * Math.PI / 180) * 0.6; // use 60% of arc
while (maxW > arcLen && fontSize > 4) {
fontSize -= 0.5;
ctx.font = `bold ${fontSize}px Arial`;
while (maxW > arcLen && fontSize > LABEL_CONFIG.minFontSize) {
fontSize -= LABEL_CONFIG.fontSizeStep;
ctx.font = `bold ${fontSize}px ${LABEL_CONFIG.fontFamily}`;
maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
}
if (fontSize >= 4) {
if (fontSize >= LABEL_CONFIG.minFontSize) {
const lineH = fontSize;
const startY = -(lineCount - 1) * lineH / 2;
for (let i = 0; i < tryLines.length; i++) {

View File

@ -1,22 +1,9 @@
import { layout } from './stores/layout.svelte.js';
import { isEpcType, isInductionType, isSpurType, isCurvedType, isRectConveyanceType, isExtendoType, isPhotoeyeType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG, getCurveGeometry } from './symbols.js';
import { isEpcType, isInductionType, isSpurType, isCurvedType, isRectConveyanceType, isExtendoType, isPhotoeyeType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG, EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE, getCurveGeometry } from './symbols.js';
import { serializeSymbol, deserializeSymbol } from './serialization.js';
import { parseConveyanceLabel } from './label-utils.js';
import type { PlacedSymbol } from './types.js';
/** Parse conveyance label into display lines — same logic as renderer */
function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } {
let core = label.replace(/_?VFD\d*$/i, '');
if (core === label) core = label;
const m = core.match(/^([A-Za-z]+)(.*)$/);
if (m) {
const prefix = m[1];
const nums = m[2].replace(/_/g, '-').replace(/^-/, '');
if (nums) return { lines: [prefix, nums], stripped: [nums] };
return { lines: [prefix], stripped: [prefix] };
}
return { lines: [core], stripped: [core] };
}
/** Emit conveyance label text inside a <g> — inherits outer transform from group */
function emitConveyanceLabelInner(lines: string[], sym: PlacedSymbol) {
if (!sym.label) return;
@ -44,7 +31,7 @@ function emitConveyanceLabelInner(lines: string[], sym: PlacedSymbol) {
textRotDeg = (textRotRad * 180) / Math.PI;
} else if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w;
const fontSize = 14;
const fontSize = LABEL_CONFIG.fontSize;
const th = fontSize * textLines.length;
const optCy = Math.min(sym.h - th / 2 - 1, sym.h * 0.55);
const textTop = optCy - th / 2;
@ -75,8 +62,8 @@ function emitConveyanceLabelInner(lines: string[], sym: PlacedSymbol) {
needsMirrorFix = !!sym.mirrored;
}
const fontSize = Math.min(14, availH / textLines.length);
if (fontSize < 4) return;
const fontSize = Math.min(LABEL_CONFIG.fontSize, availH / textLines.length);
if (fontSize < LABEL_CONFIG.minFontSize) return;
const lineH = fontSize;
// Build transform: counter-mirror then rotate for readability
let transformParts: string[] = [];
@ -85,8 +72,8 @@ function emitConveyanceLabelInner(lines: string[], sym: PlacedSymbol) {
const rotAttr = transformParts.length ? ` transform="${transformParts.join(' ')}"` : '';
for (let i = 0; i < textLines.length; i++) {
const dy = -(textLines.length - 1) * lineH / 2 + i * lineH;
const y = labelCy + dy + fontSize * 0.35;
lines.push(` <text x="${labelCx}" y="${y}" text-anchor="middle" style="font-family:Arial;font-weight:bold;font-size:${fontSize}px;fill:#000000"${rotAttr}>${textLines[i]}</text>`);
const y = labelCy + dy + fontSize * LABEL_CONFIG.baselineOffset;
lines.push(` <text x="${labelCx}" y="${y}" text-anchor="middle" style="font-family:${LABEL_CONFIG.fontFamily};font-weight:bold;font-size:${fontSize}px;fill:${LABEL_CONFIG.color}"${rotAttr}>${textLines[i]}</text>`);
}
}
@ -111,44 +98,49 @@ export function exportJSON() {
downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), `${mcmName}_layout.json`);
}
/** Declarative tag-path rules: order matters (longer/more-specific patterns first).
* Each rule maps a label suffix pattern to the Ignition folder path. */
const TAG_PATH_RULES: Array<{ pattern: RegExp; path: string }> = [
// VFD / EPC
{ pattern: /_VFD\d*$/i, path: 'VFD/APF' },
{ pattern: /_EPC\d*$/i, path: 'VFD/APF' },
// Sensors
{ pattern: /_TPE\d*$/i, path: 'Sensor/Tracking' },
{ pattern: /_LPE\d*$/i, path: 'Sensor/Long_Range' },
{ pattern: /_JPE\d*$/i, path: 'Sensor/Jam' },
{ pattern: /_FPE\d*$/i, path: 'Sensor/Full' },
{ pattern: /_PS\d*$/i, path: 'Sensor/Pressure' },
{ pattern: /_BDS\d+_[RS]$/i, path: 'Sensor/Tracking' },
{ pattern: /_TS\d+_[RS]$/i, path: 'Sensor/Tracking' },
// Network nodes
{ pattern: /_FIOM\d*$/i, path: 'Network_Node/FIO' },
{ pattern: /_FIOH\d*$/i, path: 'Network_Node/HUB' },
{ pattern: /_SIO\d*$/i, path: 'Network_Node/SIO' },
{ pattern: /_DPM\d*$/i, path: 'Network_Node/DPM' },
// Station — order matters: longer patterns first
{ pattern: /_SS\d+_S(?:T)?PB$/i, path: 'Station/Start_Stop' },
{ pattern: /_CH\d*_EN\d*_PB$/i, path: 'Station/Jam_Reset_Chute_Bank' },
{ pattern: /_JR\d*_PB$/i, path: 'Station/Jam_Reset' },
{ pattern: /_S\d+_PB$/i, path: 'Station/Start' },
// Beacon
{ pattern: /_BCN\d*$/i, path: 'Beacon' },
// Solenoid (including DIV*_SOL)
{ pattern: /_SOL\d*$/i, path: 'Solenoid' },
// Diverter (DIV*_LS)
{ pattern: /_DIV\d+_LS\d*$/i, path: 'Diverter' },
// PDP
{ pattern: /^PDP\d*/i, path: 'PDP' },
];
/** Build Ignition tag path from device label and MCM name.
* Format: System/{MCM}/{Category}/{SubCategory}/{Label}
* Mappings derived from Excel device manifest suffixes. */
function getIgnitionTagPath(label: string, mcm: string): string | null {
if (!label) return null;
// VFD / EPC
if (/_VFD\d*$/i.test(label)) return `System/${mcm}/VFD/APF/${label}`;
if (/_EPC\d*$/i.test(label)) return `System/${mcm}/VFD/APF/${label}`;
// Sensors
if (/_TPE\d*$/i.test(label)) return `System/${mcm}/Sensor/Tracking/${label}`;
if (/_LPE\d*$/i.test(label)) return `System/${mcm}/Sensor/Long_Range/${label}`;
if (/_JPE\d*$/i.test(label)) return `System/${mcm}/Sensor/Jam/${label}`;
if (/_FPE\d*$/i.test(label)) return `System/${mcm}/Sensor/Full/${label}`;
if (/_PS\d*$/i.test(label)) return `System/${mcm}/Sensor/Pressure/${label}`;
if (/_BDS\d+_[RS]$/i.test(label)) return `System/${mcm}/Sensor/Tracking/${label}`;
if (/_TS\d+_[RS]$/i.test(label)) return `System/${mcm}/Sensor/Tracking/${label}`;
// Network nodes
if (/_FIOM\d*$/i.test(label)) return `System/${mcm}/Network_Node/FIO/${label}`;
if (/_FIOH\d*$/i.test(label)) return `System/${mcm}/Network_Node/HUB/${label}`;
if (/_SIO\d*$/i.test(label)) return `System/${mcm}/Network_Node/SIO/${label}`;
if (/_DPM\d*$/i.test(label)) return `System/${mcm}/Network_Node/DPM/${label}`;
// Station — order matters: longer patterns first
if (/_SS\d+_S(?:T)?PB$/i.test(label)) return `System/${mcm}/Station/Start_Stop/${label}`;
if (/_CH\d*_EN\d*_PB$/i.test(label)) return `System/${mcm}/Station/Jam_Reset_Chute_Bank/${label}`;
if (/_JR\d*_PB$/i.test(label)) return `System/${mcm}/Station/Jam_Reset/${label}`;
if (/_S\d+_PB$/i.test(label)) return `System/${mcm}/Station/Start/${label}`;
// Beacon
if (/_BCN\d*$/i.test(label)) return `System/${mcm}/Beacon/${label}`;
// Solenoid (including DIV*_SOL)
if (/_SOL\d*$/i.test(label)) return `System/${mcm}/Solenoid/${label}`;
// Diverter (DIV*_LS)
if (/_DIV\d+_LS\d*$/i.test(label)) return `System/${mcm}/Diverter/${label}`;
// PDP
if (/^PDP\d*/i.test(label)) return `System/${mcm}/PDP/${label}`;
// MCM
if (/^MCM\d*/i.test(label)) return null;
for (const rule of TAG_PATH_RULES) {
if (rule.pattern.test(label)) return `System/${mcm}/${rule.path}/${label}`;
}
return null;
}
@ -226,7 +218,7 @@ async function buildSvgString(): Promise<string> {
const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const);
const d = `M ${sym.x + sym.w},${stripTopY} L ${pts[0][0]},${stripTopY} ${pts.map(([px, py]) => `L ${px},${py}`).join(' ')} L ${pts[5][0]},${stripBottomY} L ${sym.x + sym.w},${stripBottomY} Z`;
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
lines.push(` <path d="${d}" fill="#ffffff" stroke="#000000" stroke-width="1" />`);
lines.push(` <path d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
lines.push(` </g>`);
} else if (isCurvedType(sym.symbolId)) {
@ -246,41 +238,42 @@ async function buildSvgString(): Promise<string> {
'Z',
].join(' ');
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
lines.push(` <path d="${d}" fill="#ffffff" stroke="#000000" stroke-width="1" />`);
lines.push(` <path d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
lines.push(` </g>`);
} else if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w;
const d = `M ${sym.x},${sym.y} L ${sym.x + w2},${sym.y} L ${sym.x + sym.w},${sym.y + sym.h} L ${sym.x},${sym.y + sym.h} Z`;
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
lines.push(` <path d="${d}" fill="#ffffff" stroke="#000000" stroke-width="1" />`);
lines.push(` <path d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
lines.push(` </g>`);
} else if (isRectConveyanceType(sym.symbolId)) {
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
lines.push(` <rect x="${sym.x}" y="${sym.y}" width="${sym.w}" height="${sym.h}" fill="#ffffff" stroke="#000000" stroke-width="1" />`);
lines.push(` <rect x="${sym.x}" y="${sym.y}" width="${sym.w}" height="${sym.h}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
lines.push(` </g>`);
} else if (isExtendoType(sym.symbolId)) {
const bracketW = 10.6 / 31.07 * 73;
const bracketW = EXTENDO_CONFIG.bracketWidthRatio * EXTENDO_CONFIG.defaultWidth;
const p = EXTENDO_CONFIG.points;
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
const pts = [
[x + bracketW * 0.44, y + h * 0.085],
[x + bracketW, y + h * 0.085],
[x + bracketW, y + h * 0.222],
[x + w, y + h * 0.222],
[x + w, y + h * 0.780],
[x + bracketW, y + h * 0.780],
[x + bracketW, y + h * 0.917],
[x + bracketW * 0.44, y + h * 0.916],
[x + bracketW * 0.34, y + h * 0.985],
[x, y + h * 0.980],
[x, y + h * 0.017],
[x + bracketW * 0.34, y + h * 0.016],
[x + bracketW * p.tabTopRight.x, y + h * p.tabTopRight.y],
[x + bracketW * p.bracketTopRight.x, y + h * p.bracketTopRight.y],
[x + bracketW * p.beltTop.x, y + h * p.beltTop.y],
[x + w, y + h * p.beltTop.y],
[x + w, y + h * p.beltBottom.y],
[x + bracketW * p.beltBottom.x, y + h * p.beltBottom.y],
[x + bracketW * p.bracketBottomRight.x, y + h * p.bracketBottomRight.y],
[x + bracketW * p.bracketBottomLeft.x, y + h * p.bracketBottomLeft.y],
[x + bracketW * p.notchBottom.x, y + h * p.notchBottom.y],
[x, y + h * p.farLeftBottom.y],
[x, y + h * p.farLeftTop.y],
[x + bracketW * p.tabTopLeft.x, y + h * p.tabTopLeft.y],
];
const d = `M ${pts[0][0]},${pts[0][1]} ` + pts.slice(1).map(p => `L ${p[0]},${p[1]}`).join(' ') + ' Z';
lines.push(` <g ${idAttr}${outerTransform ? ` transform="${outerTransform}"` : ''}>`);
lines.push(` <path d="${d}" fill="#ffffff" stroke="#000000" stroke-width="1" />`);
lines.push(` <path d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}" />`);
emitConveyanceLabelInner(lines, sym as PlacedSymbol);
lines.push(` </g>`);
} else if (isPhotoeyeType(sym.symbolId)) {
@ -301,7 +294,7 @@ async function buildSvgString(): Promise<string> {
[x + w - rightCap, y + h * 0.42],
];
const d = `M ${pts[0][0]},${pts[0][1]} ` + pts.slice(1).map(p => `L ${p[0]},${p[1]}`).join(' ') + ' Z';
lines.push(` <path ${idAttr} d="${d}" fill="#ffffff" stroke="#000000" stroke-width="1"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
lines.push(` <path ${idAttr} d="${d}" fill="${CONVEYANCE_STYLE.fillColor}" stroke="${CONVEYANCE_STYLE.strokeColor}" stroke-width="${CONVEYANCE_STYLE.lineWidth}"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
} else {
// Regular SVG symbol
try {

View File

@ -0,0 +1,21 @@
/** Shared conveyance label parsing — used by both canvas renderer and SVG export */
/** Parse a conveyance label like "UL17_22_VFD" into display lines.
* Strips known suffixes (_VFD, _VFD1, etc.), splits prefix from numbers,
* replaces underscores with dashes in the number part. */
export function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } {
// Strip known device suffixes
let core = label.replace(/_?VFD\d*$/i, '');
// Split into letter prefix and rest
const m = core.match(/^([A-Za-z]+)(.*)$/);
if (m) {
const prefix = m[1];
const nums = m[2].replace(/_/g, '-').replace(/^-/, '');
if (nums) {
return { lines: [prefix, nums], stripped: [nums] };
}
return { lines: [prefix], stripped: [prefix] };
}
return { lines: [core], stripped: [core] };
}

View File

@ -33,6 +33,40 @@ export const PHOTOEYE_CONFIG = {
defaultWidth: 56, // original PE width
} as const;
export const EXTENDO_CONFIG = {
bracketWidthRatio: 10.6 / 31.07,
defaultWidth: 73,
points: {
tabTopRight: { x: 0.44, y: 0.085 },
bracketTopRight: { x: 1, y: 0.085 },
beltTop: { x: 1, y: 0.222 },
beltBottom: { x: 1, y: 0.780 },
bracketBottomRight: { x: 1, y: 0.917 },
bracketBottomLeft: { x: 0.44, y: 0.916 },
notchBottom: { x: 0.34, y: 0.985 },
farLeftBottom: { x: 0, y: 0.980 },
farLeftTop: { x: 0, y: 0.017 },
tabTopLeft: { x: 0.34, y: 0.016 },
},
} as const;
export const LABEL_CONFIG = {
fontSize: 14,
minFontSize: 4,
fontSizeStep: 0.5,
padding: 2,
font: 'bold 14px Arial',
fontFamily: 'Arial',
baselineOffset: 0.35,
color: '#000000',
} as const;
export const CONVEYANCE_STYLE = {
fillColor: '#ffffff',
strokeColor: '#000000',
lineWidth: 1,
} as const;
export const CURVE_CONFIG = {
// Fractions of display size, derived from SVG viewBox "-2 -2 104 104"
// All curved SVGs have arc center at viewBox (0,100), outerR=95

View File

@ -1,6 +1,6 @@
import type { SymbolDef } from './types.js';
import { EPC_CONFIG, INDUCTION_CONFIG, CURVE_CONFIG, PHOTOEYE_CONFIG } from './symbol-config.js';
export { EPC_CONFIG, INDUCTION_CONFIG, CURVE_CONFIG, PHOTOEYE_CONFIG };
import { EPC_CONFIG, INDUCTION_CONFIG, CURVE_CONFIG, PHOTOEYE_CONFIG, EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE } from './symbol-config.js';
export { EPC_CONFIG, INDUCTION_CONFIG, CURVE_CONFIG, PHOTOEYE_CONFIG, EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE };
export const SYMBOLS: SymbolDef[] = [
// --- Conveyance > Conveyor ---