Files
Aislo/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts
T
eomsangdonandClaude Opus 5 4cb9b15939 style: 저장소 전체 포맷터 일괄 적용 (prettier·biome·ruff)
파일마다 포맷 폭이 달라(≈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>
2026-09-02 07:08:24 +09:00

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