파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량 재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만. - 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100) - `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100). `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외 - 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경) 두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물 폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가 `core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가 실제 포맷 차이를 가리고 있었음. 검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped / 0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음). 포맷터 재실행 시 prettier·biome 모두 변경 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
/**
|
|
* 점 몇 개로 정의되는 도형의 좌표 계산 — 순수 함수.
|
|
* 정다각형·타원·스플라인·구름형처럼 폴리선으로 근사해 그리는 도형이 여기서 나온다.
|
|
*/
|
|
import { Point } from '@flatten-js/core';
|
|
|
|
export interface ArcDefinition {
|
|
center: Point;
|
|
radius: number;
|
|
startAngle: number;
|
|
endAngle: number;
|
|
counterClockwise: boolean;
|
|
}
|
|
|
|
/** 3점(시작·통과·끝)을 지나는 호. 세 점이 일직선이면 null */
|
|
export function arcThroughThreePoints(
|
|
start: Point,
|
|
through: Point,
|
|
end: Point
|
|
): ArcDefinition | null {
|
|
const ax = start.x;
|
|
const ay = start.y;
|
|
const bx = through.x;
|
|
const by = through.y;
|
|
const cx = end.x;
|
|
const cy = end.y;
|
|
|
|
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
|
if (Math.abs(d) < 1e-9) return null; // 일직선
|
|
|
|
const ux =
|
|
((ax * ax + ay * ay) * (by - cy) +
|
|
(bx * bx + by * by) * (cy - ay) +
|
|
(cx * cx + cy * cy) * (ay - by)) /
|
|
d;
|
|
const uy =
|
|
((ax * ax + ay * ay) * (cx - bx) +
|
|
(bx * bx + by * by) * (ax - cx) +
|
|
(cx * cx + cy * cy) * (bx - ax)) /
|
|
d;
|
|
|
|
const center = new Point(ux, uy);
|
|
const radius = center.distanceTo(start)[0];
|
|
const startAngle = Math.atan2(ay - uy, ax - ux);
|
|
const throughAngle = Math.atan2(by - uy, bx - ux);
|
|
const endAngle = Math.atan2(cy - uy, cx - ux);
|
|
|
|
// 통과점이 시작→끝 사이에 오도록 회전 방향을 고른다
|
|
const counterClockwise = isAngleBetween(throughAngle, startAngle, endAngle, true);
|
|
return { center, radius, startAngle, endAngle, counterClockwise };
|
|
}
|
|
|
|
/** 반시계(또는 시계) 방향으로 start에서 end로 갈 때 angle을 지나는가 */
|
|
export function isAngleBetween(
|
|
angle: number,
|
|
start: number,
|
|
end: number,
|
|
counterClockwise: boolean
|
|
): boolean {
|
|
const normalize = (value: number) => ((value % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
const sweep = counterClockwise ? normalize(end - start) : normalize(start - end);
|
|
const offset = counterClockwise ? normalize(angle - start) : normalize(start - angle);
|
|
return offset <= sweep;
|
|
}
|
|
|
|
/** 정다각형 — 중심과 첫 꼭짓점, 변 수 */
|
|
export function regularPolygonPoints(center: Point, vertex: Point, sides: number): Point[] {
|
|
const count = Math.max(3, Math.round(sides));
|
|
const radius = center.distanceTo(vertex)[0];
|
|
const startAngle = Math.atan2(vertex.y - center.y, vertex.x - center.x);
|
|
const points: Point[] = [];
|
|
for (let index = 0; index < count; index++) {
|
|
const angle = startAngle + (2 * Math.PI * index) / count;
|
|
points.push(
|
|
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
|
|
);
|
|
}
|
|
points.push(points[0].clone());
|
|
return points;
|
|
}
|
|
|
|
/** 타원 — 중심, 장축 끝점, 단축 반지름. 폴리선으로 근사한다 */
|
|
export function ellipsePoints(
|
|
center: Point,
|
|
majorPoint: Point,
|
|
minorRadius: number,
|
|
segments = 72
|
|
): Point[] {
|
|
const majorRadius = center.distanceTo(majorPoint)[0];
|
|
const rotation = Math.atan2(majorPoint.y - center.y, majorPoint.x - center.x);
|
|
const cos = Math.cos(rotation);
|
|
const sin = Math.sin(rotation);
|
|
const points: Point[] = [];
|
|
for (let index = 0; index <= segments; index++) {
|
|
const angle = (2 * Math.PI * index) / segments;
|
|
const x = majorRadius * Math.cos(angle);
|
|
const y = minorRadius * Math.sin(angle);
|
|
points.push(new Point(center.x + x * cos - y * sin, center.y + x * sin + y * cos));
|
|
}
|
|
return points;
|
|
}
|
|
|
|
/** 조정점을 지나는 부드러운 곡선 (Catmull-Rom → 폴리선) */
|
|
export function splinePoints(controlPoints: Point[], segmentsPerSpan = 12): Point[] {
|
|
if (controlPoints.length < 3) return [...controlPoints];
|
|
const extended = [controlPoints[0], ...controlPoints, controlPoints[controlPoints.length - 1]];
|
|
const result: Point[] = [];
|
|
for (let index = 1; index < extended.length - 2; index++) {
|
|
const p0 = extended[index - 1];
|
|
const p1 = extended[index];
|
|
const p2 = extended[index + 1];
|
|
const p3 = extended[index + 2];
|
|
for (let step = 0; step < segmentsPerSpan; step++) {
|
|
const t = step / segmentsPerSpan;
|
|
result.push(catmullRom(p0, p1, p2, p3, t));
|
|
}
|
|
}
|
|
result.push(controlPoints[controlPoints.length - 1]);
|
|
return result;
|
|
}
|
|
|
|
function catmullRom(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
|
|
const t2 = t * t;
|
|
const t3 = t2 * t;
|
|
const axis = (a: number, b: number, c: number, d: number) =>
|
|
0.5 * (2 * b + (c - a) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (3 * b - 3 * c + d - a) * t3);
|
|
return new Point(axis(p0.x, p1.x, p2.x, p3.x), axis(p0.y, p1.y, p2.y, p3.y));
|
|
}
|
|
|
|
/** 구름형 리비전 — 경로를 따라 반원 스캘럽을 이어붙인 점렬 */
|
|
export function revisionCloudPoints(path: Point[], arcRadius: number): Point[] {
|
|
if (path.length < 2 || arcRadius <= 0) return [...path];
|
|
const result: Point[] = [];
|
|
for (let index = 1; index < path.length; index++) {
|
|
const start = path[index - 1];
|
|
const end = path[index];
|
|
const length = start.distanceTo(end)[0];
|
|
const bulges = Math.max(1, Math.round(length / (arcRadius * 2)));
|
|
for (let bulge = 0; bulge < bulges; bulge++) {
|
|
const from = lerp(start, end, bulge / bulges);
|
|
const to = lerp(start, end, (bulge + 1) / bulges);
|
|
result.push(...halfArcPoints(from, to));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const lerp = (a: Point, b: Point, t: number): Point =>
|
|
new Point(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
|
|
|
/** 두 점을 지름으로 하는 반원 (구름형 한 칸) */
|
|
function halfArcPoints(from: Point, to: Point, segments = 8): Point[] {
|
|
const center = lerp(from, to, 0.5);
|
|
const radius = from.distanceTo(to)[0] / 2;
|
|
const baseAngle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
const points: Point[] = [];
|
|
for (let index = 0; index <= segments; index++) {
|
|
const angle = baseAngle + Math.PI - (Math.PI * index) / segments;
|
|
points.push(
|
|
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
|
|
);
|
|
}
|
|
return points;
|
|
}
|
|
|
|
/** 폴리선을 distance만큼 나란히 민 점렬 (양수: 진행 방향 왼쪽) */
|
|
export function offsetPolylinePoints(points: Point[], distance: number): Point[] {
|
|
if (points.length < 2 || distance === 0) return [...points];
|
|
const offsetLines: { start: Point; end: Point }[] = [];
|
|
for (let index = 1; index < points.length; index++) {
|
|
const start = points[index - 1];
|
|
const end = points[index];
|
|
const dx = end.x - start.x;
|
|
const dy = end.y - start.y;
|
|
const length = Math.hypot(dx, dy);
|
|
if (length < 1e-9) continue;
|
|
const nx = (-dy / length) * distance;
|
|
const ny = (dx / length) * distance;
|
|
offsetLines.push({
|
|
start: new Point(start.x + nx, start.y + ny),
|
|
end: new Point(end.x + nx, end.y + ny),
|
|
});
|
|
}
|
|
if (!offsetLines.length) return [...points];
|
|
|
|
const result: Point[] = [offsetLines[0].start];
|
|
for (let index = 1; index < offsetLines.length; index++) {
|
|
const previous = offsetLines[index - 1];
|
|
const current = offsetLines[index];
|
|
const joint = intersectLines(previous.start, previous.end, current.start, current.end);
|
|
result.push(joint ?? current.start);
|
|
}
|
|
result.push(offsetLines[offsetLines.length - 1].end);
|
|
return result;
|
|
}
|
|
|
|
/** 두 직선(무한 연장)의 교점. 평행이면 null */
|
|
export function intersectLines(a1: Point, a2: Point, b1: Point, b2: Point): Point | null {
|
|
const d1x = a2.x - a1.x;
|
|
const d1y = a2.y - a1.y;
|
|
const d2x = b2.x - b1.x;
|
|
const d2y = b2.y - b1.y;
|
|
const denominator = d1x * d2y - d1y * d2x;
|
|
if (Math.abs(denominator) < 1e-12) return null;
|
|
const t = ((b1.x - a1.x) * d2y - (b1.y - a1.y) * d2x) / denominator;
|
|
return new Point(a1.x + d1x * t, a1.y + d1y * t);
|
|
}
|