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