feat(viewer2d): add complex linetype rendering & LIN pattern parser support

This commit is contained in:
minsung
2026-07-30 17:06:38 +09:00
parent 76f3e20582
commit 1a664f677a
5 changed files with 2214 additions and 206 deletions
+395 -15
View File
@@ -11,6 +11,7 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { aciToHex } from './aciColors.js';
import { buildAllowedOwnerHexes, listCadSpaces } from './cadSpaces';
import { SlugTextEngine, SlugTextBatch } from './slugText';
import { getLinPattern } from './linParser';
const DEFAULT_COLOR = 0xc9d1d9;
const ARC_SEGS = 64;
@@ -424,7 +425,9 @@ export class Viewer2D {
const d = e.data || e;
const type = (e.type || e.typeName || '').toUpperCase();
const color = this._entityColor(e);
curDash = this._resolveDash(e);
const complexLt = this._resolveComplexLinetype(e);
if (complexLt) curDash = null;
else curDash = this._resolveDash(e);
const meta = { entity: e, type, bounds: null, colStart: lineColors.length };
this._entityMeta.push(meta);
this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below
@@ -435,21 +438,35 @@ export class Viewer2D {
// ── Basic geometry ────────────────────────────────────────────────
case 'LINE':
if (d.start && d.end) {
pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color);
if (complexLt) {
this._drawComplexPath([d.start, d.end], false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color);
}
meta.bounds = { type:'line', x1:d.start.x, y1:d.start.y, x2:d.end.x, y2:d.end.y };
}
break;
case 'CIRCLE':
if (d.center && d.radius != null) {
this._arcSegs(d.center, d.radius, 0, Math.PI*2, d.center.z||0, color, pushSeg);
if (complexLt) {
const pts = this._sampleArcPoints(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0);
this._drawComplexPath(pts, true, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
this._arcSegs(d.center, d.radius, 0, Math.PI*2, d.center.z||0, color, pushSeg);
}
meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius };
}
break;
case 'ARC':
if (d.center && d.radius != null) {
this._arcSegs(d.center, d.radius, d.startAngle??0, d.endAngle??Math.PI*2, d.center.z||0, color, pushSeg);
if (complexLt) {
const pts = this._sampleArcPoints(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0);
this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
this._arcSegs(d.center, d.radius, d.startAngle??0, d.endAngle??Math.PI*2, d.center.z||0, color, pushSeg);
}
meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius };
}
break;
@@ -461,6 +478,10 @@ export class Viewer2D {
const nSeg = closed ? d.points.length : d.points.length - 1;
if (this._polyHasWidth(e, nSeg, 1)) {
this._widePolyMesh(d.points, d.bulges, closed, e, color, 1, expand);
} else if (complexLt) {
const pts = this._samplePolylinePoints(d.points, d.bulges, closed, d.elevation || 0);
this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach(p => expand(p.x, p.y, p.z));
} else if (hasBulge) {
this._bulgePolySegs(d.points, d.bulges, closed, color, pushSeg, expand);
} else {
@@ -474,7 +495,14 @@ export class Viewer2D {
break;
case 'POLYLINE':
this._polylineSegs(d.vertices||d.points, d.closed||(d.flags&1), 0, color, pushSeg);
if (complexLt && (d.vertices || d.points)) {
const closed = !!(d.closed || (d.flags & 1));
const pts = d.vertices || d.points;
this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
} else {
this._polylineSegs(d.vertices||d.points, d.closed||(d.flags&1), 0, color, pushSeg);
}
break;
case 'POLYLINE_2D': {
@@ -487,7 +515,11 @@ export class Viewer2D {
const bulges = kids.map(v => v.bulge || 0);
const closed = (d.flags & 1) === 1;
const elev = d.elevation || 0;
if (bulges.some(b => Math.abs(b) >= 1e-6)) {
if (complexLt) {
const sampled = this._samplePolylinePoints(pts, bulges, closed, elev);
this._drawComplexPath(sampled, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
sampled.forEach(p => expand(p.x, p.y, p.z));
} else if (bulges.some(b => Math.abs(b) >= 1e-6)) {
this._bulgePolySegs(pts, bulges, closed, color, pushSeg, expand);
} else {
this._polylineSegs(pts, closed, elev, color, pushSeg);
@@ -511,7 +543,13 @@ export class Viewer2D {
case 'ELLIPSE':
if (d.center) {
this._ellipseSegs(d, color, pushSeg);
if (complexLt) {
const pts = this._sampleEllipsePoints(d);
this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
} else {
this._ellipseSegs(d, color, pushSeg);
}
meta.bounds = { type:'point', cx:d.center.x, cy:d.center.y };
}
break;
@@ -530,8 +568,13 @@ export class Viewer2D {
return result;
})()
: cps;
this._polylineSegs(pts, false, 0, color, pushSeg);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
if (complexLt) {
this._drawComplexPath(pts, !!d.closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
} else {
this._polylineSegs(pts, false, 0, color, pushSeg);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
}
meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y };
}
break;
@@ -1247,29 +1290,50 @@ export class Viewer2D {
const d = e.data || e;
const type = (e.type || e.typeName || '').toUpperCase();
const color = this._entityColor(e);
const complexLt = this._resolveComplexLinetype(e);
const vpTextStart = pendingTexts.length;
try {
switch (type) {
case 'LINE':
if (d.start && d.end) {
pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color);
if (complexLt) {
this._drawComplexPath([d.start, d.end], false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color);
}
}
break;
case 'CIRCLE':
if (d.center && d.radius != null) {
// pushSeg maps model endpoints → paper; keep radius in model units
this._arcSegs(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0, color, pushSeg);
if (complexLt) {
const pts = this._sampleArcPoints(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0);
this._drawComplexPath(pts, true, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
// pushSeg maps model endpoints → paper; keep radius in model units
this._arcSegs(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0, color, pushSeg);
}
}
break;
case 'ARC':
if (d.center && d.radius != null) {
this._arcSegs(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0, color, pushSeg);
if (complexLt) {
const pts = this._sampleArcPoints(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0);
this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
this._arcSegs(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0, color, pushSeg);
}
}
break;
case 'LWPOLYLINE':
if (d.points?.length) {
const hasBulge = d.bulges?.some((b) => Math.abs(b) >= 1e-6);
const closed = !!(d.closed || (d.flags & 1));
if (hasBulge) this._bulgePolySegs(d.points, d.bulges, closed, color, pushSeg, expand);
if (complexLt) {
const pts = this._samplePolylinePoints(d.points, d.bulges, closed, d.elevation || 0);
this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach((p) => expand(p.x, p.y, p.z));
} else if (hasBulge) this._bulgePolySegs(d.points, d.bulges, closed, color, pushSeg, expand);
else {
this._polylineSegs(d.points, closed, d.elevation || 0, color, pushSeg);
d.points.forEach((p) => expand(p.x, p.y, d.elevation || 0));
@@ -1277,7 +1341,14 @@ export class Viewer2D {
}
break;
case 'POLYLINE':
this._polylineSegs(d.vertices || d.points, d.closed || (d.flags & 1), 0, color, pushSeg);
if (complexLt && (d.vertices || d.points)) {
const closed = !!(d.closed || (d.flags & 1));
const pts = d.vertices || d.points;
this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach((p) => expand(p.x, p.y, p.z || 0));
} else {
this._polylineSegs(d.vertices || d.points, d.closed || (d.flags & 1), 0, color, pushSeg);
}
break;
case 'ELLIPSE':
if (d.center) {
@@ -1410,6 +1481,17 @@ export class Viewer2D {
default:
break;
}
if (complexLt && pendingTexts.length > vpTextStart) {
for (let ti = vpTextStart; ti < pendingTexts.length; ti++) {
const t = pendingTexts[ti];
if (!t?.pos) continue;
const q = xf(t.pos.x, t.pos.y);
t.pos = q;
t.height = (t.height || 2.5) * scale;
t.rotation = (t.rotation || 0) + (xf.twist || 0);
t.underlay = !!underlay;
}
}
} catch { /* skip malformed */ }
}
@@ -1466,6 +1548,304 @@ export class Viewer2D {
// ── Private helpers ────────────────────────────────────────────────────────
_resolveComplexLinetype(e) {
let ltName = e.lineType ?? e.linetype;
if (!ltName || ltName === 'ByLayer' || ltName === 'BYLAYER' ||
ltName === 'ByBlock' || ltName === 'BYBLOCK') {
const lh = e.layerHandle?.value ?? e.layerHandle;
ltName = (lh != null && this._layerLtByHandle?.get(String(lh)))
|| this._layerLtByName?.get(e.layer ?? e.layerName) || null;
}
if (!ltName || ltName === 'Continuous' || ltName === 'ByLayer' || ltName === 'ByBlock') return null;
const pattern = getLinPattern(ltName);
if (!pattern) return null;
const entScale = (e.entityHeader?.linetypeScale > 0 ? e.entityHeader.linetypeScale : 1);
const ltscale = (this._globalLtscale || 1) * entScale;
return { pattern, ltscale };
}
_sampleArcPoints(center, radius, startAngle, endAngle, elevation = 0) {
let sweep = endAngle - startAngle;
if (sweep <= 0) sweep += Math.PI * 2;
const steps = Math.max(16, Math.ceil((sweep / (Math.PI * 2)) * ARC_SEGS));
const pts = [];
const z = center.z || elevation || 0;
for (let i = 0; i <= steps; i++) {
const a = startAngle + (sweep * i) / steps;
pts.push({
x: center.x + radius * Math.cos(a),
y: center.y + radius * Math.sin(a),
z,
});
}
return pts;
}
_samplePolylinePoints(points, bulges, closed, elevation = 0) {
if (!points || points.length < 2) return points || [];
const res = [];
const n = closed ? points.length : points.length - 1;
for (let i = 0; i < n; i++) {
const u = points[i];
const v = points[(i + 1) % points.length];
const b = bulges?.[i] || 0;
const z = u.z || elevation || 0;
if (Math.abs(b) < 1e-6) {
if (res.length === 0) res.push({ x: u.x, y: u.y, z });
res.push({ x: v.x, y: v.y, z });
} else {
const dx = v.x - u.x;
const dy = v.y - u.y;
const dist = Math.hypot(dx, dy);
if (dist > 1e-9) {
const theta = 4 * Math.atan(b);
const halfTheta = Math.abs(theta) / 2;
const R = dist / 2 / Math.sin(halfTheta);
const dCenter = R * Math.cos(halfTheta);
const mx = (u.x + v.x) / 2;
const my = (u.y + v.y) / 2;
const nx = -dy / dist;
const ny = dx / dist;
const dir = b > 0 ? 1 : -1;
const cx = mx + dir * nx * dCenter;
const cy = my + dir * ny * dCenter;
const a1 = Math.atan2(u.y - cy, u.x - cx);
const steps = Math.max(4, Math.ceil(Math.abs(theta) / (Math.PI / 16)));
for (let k = (res.length === 0 ? 0 : 1); k <= steps; k++) {
const a = a1 + (theta * k) / steps;
res.push({
x: cx + R * Math.cos(a),
y: cy + R * Math.sin(a),
z,
});
}
}
}
}
return res;
}
_sampleEllipsePoints(d) {
const major = d.majorAxis ?? d.smAxis ?? { x: d.radius || 1, y: 0 };
const center = d.center || { x: 0, y: 0 };
const ratio = d.ratio ?? d.axisRatio ?? d.minorAxisRatio ?? 0.5;
const startParam = d.startParam ?? d.startAngle ?? 0;
const endParam = d.endParam ?? d.endAngle ?? Math.PI * 2;
const aLen = Math.hypot(major.x ?? 1, major.y ?? 0);
const bLen = aLen * ratio;
const rot = Math.atan2(major.y ?? 0, major.x ?? 1);
let sweep = endParam - startParam;
if (sweep <= 0) sweep += Math.PI * 2;
const steps = ELLIPSE_SEGS;
const pts = [];
const z = center.z || 0;
for (let i = 0; i <= steps; i++) {
const t = startParam + (sweep * i) / steps;
const ex = aLen * Math.cos(t);
const ey = bLen * Math.sin(t);
const x = center.x + ex * Math.cos(rot) - ey * Math.sin(rot);
const y = center.y + ex * Math.sin(rot) + ey * Math.cos(rot);
pts.push({ x, y, z });
}
return pts;
}
_drawComplexPath(points, closed, linPattern, ltscale, color, pushSeg, pendingTexts) {
if (!points || points.length < 2) return;
const pts = points.slice();
if (closed && (Math.hypot(pts[pts.length - 1].x - pts[0].x, pts[pts.length - 1].y - pts[0].y) > 1e-6)) {
pts.push(pts[0]);
}
const segLens = [];
let totalLen = 0;
for (let i = 0; i < pts.length - 1; i++) {
const dx = pts[i + 1].x - pts[i].x;
const dy = pts[i + 1].y - pts[i].y;
const len = Math.hypot(dx, dy);
segLens.push(len);
totalLen += len;
}
if (totalLen < 1e-6) return;
const getPathState = (dist) => {
let s = Math.max(0, Math.min(dist, totalLen));
for (let i = 0; i < segLens.length; i++) {
const len = segLens[i];
if (s <= len || i === segLens.length - 1) {
const t = len > 1e-9 ? s / len : 0;
const p0 = pts[i];
const p1 = pts[i + 1];
const x = p0.x + (p1.x - p0.x) * t;
const y = p0.y + (p1.y - p0.y) * t;
const z = (p0.z || 0) + ((p1.z || 0) - (p0.z || 0)) * t;
const angle = Math.atan2(p1.y - p0.y, p1.x - p0.x);
return { x, y, z, angle };
}
s -= len;
}
const last = pts[pts.length - 1];
const prev = pts[pts.length - 2];
return {
x: last.x,
y: last.y,
z: last.z || 0,
angle: Math.atan2(last.y - prev.y, last.x - prev.x),
};
};
const patternLen = linPattern.patternLength * ltscale;
if (patternLen < 1e-6) return;
let s = 0;
let elemIdx = 0;
const elements = linPattern.elements;
while (s < totalLen) {
const elem = elements[elemIdx % elements.length];
elemIdx++;
if (elem.type === 'dash') {
const dashLen = Math.max((elem.val || 0.05) * ltscale, 0.01 * ltscale);
const startDist = s;
const endDist = Math.min(s + dashLen, totalLen);
if (endDist > startDist) {
const pStart = getPathState(startDist);
const pEnd = getPathState(endDist);
pushSeg(pStart.x, pStart.y, pEnd.x, pEnd.y, pStart.z, color);
}
s += dashLen;
} else if (elem.type === 'gap') {
const gapLen = Math.abs(elem.val || 0.1) * ltscale;
s += gapLen;
} else if (elem.type === 'shape') {
const pState = getPathState(s);
this._renderShapeSymbol(elem, pState, ltscale, color, pushSeg);
} else if (elem.type === 'text') {
const pState = getPathState(s);
if (elem.text && pendingTexts) {
const h = (elem.scale || 0.2) * ltscale;
let angle = pState.angle + ((elem.rotation || 0) * Math.PI) / 180;
if (elem.isAbsoluteAngle) {
angle = ((elem.rotation || 0) * Math.PI) / 180;
}
// CAD Standard Upright Rule: text must always read from bottom or right (-90deg to +90deg)
let normA = Math.atan2(Math.sin(angle), Math.cos(angle));
if (normA > Math.PI / 2) normA -= Math.PI;
else if (normA < -Math.PI / 2) normA += Math.PI;
angle = normA;
const cosT = Math.cos(angle);
const sinT = Math.sin(angle);
const xo = (elem.xOffset || 0) * ltscale;
const rawYo = (elem.yOffset != null && Math.abs(elem.yOffset) > 1e-4) ? Math.abs(elem.yOffset) : 0.02;
const yo = rawYo * ltscale;
const tx = pState.x + (xo * cosT - yo * sinT);
const ty = pState.y + (xo * sinT + yo * cosT);
pendingTexts.push({
text: elem.text,
pos: { x: tx, y: ty, z: pState.z },
height: Math.max(h, 0.01),
rotation: angle,
color,
alignH: 1,
alignV: 2,
});
}
}
}
}
_renderShapeSymbol(elem, pState, ltscale, color, pushSeg) {
const name = (elem.shapeName || '').toUpperCase();
const sc = (elem.scale || 0.1) * ltscale;
const angle = elem.isAbsoluteAngle
? ((elem.rotation || 0) * Math.PI) / 180
: pState.angle + ((elem.rotation || 0) * Math.PI) / 180;
const xo = (elem.xOffset || 0) * ltscale;
const yo = (elem.yOffset || 0) * ltscale;
const cosA = Math.cos(pState.angle);
const sinA = Math.sin(pState.angle);
const cx = pState.x + (xo * cosA - yo * sinA);
const cy = pState.y + (xo * sinA + yo * cosA);
const cz = pState.z || 0;
const cosR = Math.cos(angle);
const sinR = Math.sin(angle);
const transform = (lx, ly) => ({
x: cx + (lx * cosR - ly * sinR) * sc,
y: cy + (lx * sinR + ly * cosR) * sc,
});
if (name.includes('KSC35') || name.includes('KSC36') || name.includes('KSC19') || name.includes('ARROW')) {
// Solid hatched arrowhead (matching AutoCAD KSC35 standard symbol)
const steps = 12;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const lx = -0.5 + t;
const hy = 0.25 * (1 - t);
const top = transform(lx, hy);
const bot = transform(lx, -hy);
pushSeg(top.x, top.y, bot.x, bot.y, cz, color);
}
const p1 = transform(0.5, 0);
const p2 = transform(-0.5, 0.25);
const p3 = transform(-0.5, -0.25);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color);
pushSeg(p3.x, p3.y, p1.x, p1.y, cz, color);
} else if (name.includes('CIRC') || name.includes('KSC11') || name.includes('KSC01')) {
const segs = 12;
let prev = transform(0.3, 0);
for (let i = 1; i <= segs; i++) {
const a = (i / segs) * Math.PI * 2;
const curr = transform(0.3 * Math.cos(a), 0.3 * Math.sin(a));
pushSeg(prev.x, prev.y, curr.x, curr.y, cz, color);
prev = curr;
}
} else if (name.includes('BOX') || name.includes('KSC02') || name.includes('KSC05')) {
const p1 = transform(-0.3, -0.3);
const p2 = transform(0.3, -0.3);
const p3 = transform(0.3, 0.3);
const p4 = transform(-0.3, 0.3);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color);
pushSeg(p3.x, p3.y, p4.x, p4.y, cz, color);
pushSeg(p4.x, p4.y, p1.x, p1.y, cz, color);
} else if (name.includes('TRACK1')) {
const p1 = transform(0, -0.4);
const p2 = transform(0, 0.4);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
} else if (name.includes('BAT') || name.includes('DIAMOND')) {
const p1 = transform(0, 0.4);
const p2 = transform(0.4, 0);
const p3 = transform(0, -0.4);
const p4 = transform(-0.4, 0);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color);
pushSeg(p3.x, p3.y, p4.x, p4.y, cz, color);
pushSeg(p4.x, p4.y, p1.x, p1.y, cz, color);
} else {
const p1 = transform(0.3, 0);
const p2 = transform(-0.3, 0.2);
const p3 = transform(-0.3, -0.2);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color);
pushSeg(p3.x, p3.y, p1.x, p1.y, cz, color);
}
}
_buildLayerMap(layers) {
this._layerByHandle.clear();
this._layerByName.clear();
File diff suppressed because one or more lines are too long
+301
View File
@@ -0,0 +1,301 @@
/**
* LIN File Parser & Complex Linetype Registry
* Parses AutoCAD LIN file definitions (including text and shape descriptors).
*/
import { ICAD_LIN, KOSDIC_LIN } from './linData';
export interface LinElement {
type: 'dash' | 'gap' | 'shape' | 'text';
val?: number; // dash length (+) or gap length (-)
shapeName?: string; // e.g. KSC35, CIRC1, BOX, TRACK1, BAT, ZIG
shxFile?: string; // e.g. kosdic.shx, ltypeshp.shx
text?: string; // e.g. "L2", "GAS", "HW"
style?: string; // e.g. Standard
scale: number; // s=... (default 1.0)
rotation: number; // r=... (angle in degrees)
isAbsoluteAngle: boolean;// a=...
isUprightAngle: boolean; // u=...
xOffset: number; // x=...
yOffset: number; // y=...
}
export interface LinPattern {
name: string;
description: string;
elements: LinElement[];
patternLength: number; // total length sum of dashes + abs(gaps)
}
function parseBracketElement(inner: string): LinElement {
const parts: string[] = [];
let cur = '';
let inQuote = false;
for (let i = 0; i < inner.length; i++) {
const c = inner[i];
if (c === '"') inQuote = !inQuote;
if (c === ',' && !inQuote) {
parts.push(cur.trim());
cur = '';
} else {
cur += c;
}
}
if (cur.trim()) parts.push(cur.trim());
let type: 'shape' | 'text' = 'shape';
let shapeName: string | undefined;
let shxFile: string | undefined;
let text: string | undefined;
let style: string | undefined;
let scale = 1.0;
let rotation = 0;
let isAbsoluteAngle = false;
let isUprightAngle = false;
let xOffset = 0;
let yOffset = 0;
if (parts.length > 0) {
const first = parts[0];
if (first.startsWith('"') && first.endsWith('"')) {
type = 'text';
text = first.slice(1, -1);
if (parts.length > 1 && !parts[1].includes('=')) style = parts[1];
} else {
type = 'shape';
shapeName = first;
if (parts.length > 1 && !parts[1].includes('=')) shxFile = parts[1];
}
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const eqIdx = part.indexOf('=');
if (eqIdx > 0) {
const key = part.substring(0, eqIdx).trim().toLowerCase();
const valStr = part.substring(eqIdx + 1).trim();
const val = parseFloat(valStr);
if (!isNaN(val)) {
if (key === 's') scale = val;
else if (key === 'r') { rotation = val; }
else if (key === 'a') { rotation = val; isAbsoluteAngle = true; }
else if (key === 'u') { rotation = val; isUprightAngle = true; }
else if (key === 'x') xOffset = val;
else if (key === 'y') yOffset = val;
}
}
}
}
return {
type,
shapeName,
shxFile,
text,
style,
scale,
rotation,
isAbsoluteAngle,
isUprightAngle,
xOffset,
yOffset,
};
}
function tokenizeLinPattern(patternStr: string): string[] {
const s = patternStr.replace(/^A\s*,\s*/i, '').trim();
const tokens: string[] = [];
let current = '';
let inBracket = false;
let inQuote = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"') inQuote = !inQuote;
if (ch === '[' && !inQuote) inBracket = true;
if (ch === ']' && !inQuote) inBracket = false;
if (ch === ',' && !inBracket && !inQuote) {
if (current.trim()) tokens.push(current.trim());
current = '';
} else {
current += ch;
}
}
if (current.trim()) tokens.push(current.trim());
return tokens;
}
export function parseLinContent(content: string): Map<string, LinPattern> {
const result = new Map<string, LinPattern>();
const lines = content.split(/\r?\n/);
let currentName: string | null = null;
let currentDesc = '';
for (let line of lines) {
line = line.trim();
if (!line || line.startsWith(';')) continue;
if (line.startsWith('*')) {
const commaIdx = line.indexOf(',');
if (commaIdx > 0) {
currentName = line.substring(1, commaIdx).trim();
currentDesc = line.substring(commaIdx + 1).trim();
} else {
currentName = line.substring(1).trim();
currentDesc = '';
}
} else if (currentName && line.toUpperCase().startsWith('A,')) {
const tokens = tokenizeLinPattern(line);
const elements: LinElement[] = [];
let totalLength = 0;
for (const tok of tokens) {
if (tok.startsWith('[') && tok.endsWith(']')) {
const elem = parseBracketElement(tok.slice(1, -1));
elements.push(elem);
} else {
const val = parseFloat(tok);
if (!isNaN(val)) {
if (val > 1e-6) {
elements.push({
type: 'dash',
val,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += val;
} else if (val < -1e-6) {
const absVal = Math.abs(val);
elements.push({
type: 'gap',
val: -absVal,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += absVal;
} else {
// Dot (val === 0)
elements.push({
type: 'dash',
val: 0.05,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += 0.05;
}
}
}
}
if (elements.length > 0) {
result.set(currentName.toUpperCase(), {
name: currentName,
description: currentDesc,
elements,
patternLength: Math.max(totalLength, 0.1),
});
}
currentName = null;
}
}
return result;
}
// Global Linetype Registry initialized with Icad.lin and KOSDIC.lin
const globalRegistry = new Map<string, LinPattern>();
export function registerLinContent(content: string) {
const parsed = parseLinContent(content);
for (const [key, pattern] of parsed.entries()) {
globalRegistry.set(key, pattern);
}
}
// Warm up registry with bundled LIN files
registerLinContent(ICAD_LIN);
registerLinContent(KOSDIC_LIN);
const CIVIL_LT_TEXT_MAP: Record<string, string> = {
'H-DICHL02': 'L2-2/0',
'H-DICHL02R': 'L2-2',
'H-DICHL01': 'L2-3/0',
'H-DICHL01R': 'L2-3',
'H-DICHL06': 'L5-1',
'H-DICHL06R': 'L5-1',
'H-DICHL05': 'L5',
'H-DICHL05R': 'L5',
'H-DICHU05': 'U5',
'H-DICHU05R': 'U5',
'H-DICHB01': 'B1',
'H-DICHB01R': 'B1',
'H-DICHM03': 'M3',
'H-DICHM03R': 'M3',
'H-DICHV01': 'V1',
'H-DICHV01R': 'V1',
'H-DICHO01': 'O1',
'H-DICHO01R': 'O1',
'H-DICHL03': 'L3',
'H-DICHL03R': 'L3',
'H-DICHL04': 'L4',
'H-DICHL04R': 'L4',
'H-DICHL08': 'L8',
'H-DICHL08R': 'L8',
};
const NO_SHAPE_LINETYPES = new Set([
'H-DICHL05',
'H-DICHL05R',
'H-DICHU05',
'H-DICHU05R',
'H-DICHB01',
'H-DICHB01R',
'H-DICHM03',
'H-DICHM03R',
'H-DICHV01',
'H-DICHV01R',
'H-DICHO01',
'H-DICHO01R',
]);
export function getLinPattern(name: string): LinPattern | undefined {
if (!name) return undefined;
const key = name.toUpperCase().trim();
const pattern = globalRegistry.get(key);
if (!pattern) return undefined;
let elements = pattern.elements;
// Filter out shape/arrowhead elements for L5, U5, B1, etc. as specified in official CAD spec
if (NO_SHAPE_LINETYPES.has(key)) {
elements = elements.filter((elem) => elem.type !== 'shape');
}
const overrideText = CIVIL_LT_TEXT_MAP[key];
if (overrideText) {
elements = elements.map((elem) =>
elem.type === 'text' ? { ...elem, text: overrideText } : elem
);
}
return {
...pattern,
elements,
};
}
export function getAllRegisteredPatterns(): Map<string, LinPattern> {
return globalRegistry;
}