Files
Aislo/B07_DesignDetail/openwebcad/src/helpers/text-frame.ts
T
eomsangdonandClaude Opus 5.5 cdfd2c6663 feat(487 CAD 글 테두리 · 리본 켜짐): 스침 테두리 = 더블클릭 입력 칸 한 벌(text-frame) · 잡는 범위 = 테두리 안 · 리본 켜짐 하나만
- 새 helpers/text-frame.ts — 정렬 · 가운데 맞춤 · 실제 글꼴 폭(굵게 · 기울임) · 회전 · 여러 줄 묶음 · 도각 칸 = 테두리 한 셈
- TextEntity 스침 · 고름 테두리 = 그 네 귀(여러 줄은 묶음 하나) · distanceTo = 테두리 안 0 · getBoundingBox 그대로
- 입력 칸 자리 · 폭 · 높이 = 같은 셈(여유 = 안쪽 여백) · 더블클릭 범위 = 테두리 + 집기 반경
- measureTextScreen 글꼴 받음 · 디버그 창구 textFrame(화면 네 귀)
- 리본 스침 = 바탕만(hover 기기만) · 켜짐 = 테두리 + 밑줄 · 누른 뒤 blur(초점 테두리 남음 막음)
- 시험 text-frame.test.ts 새로 · text-inplace-edit.test.ts 칸 폭 · 여백 · test_cad_host_tabs 줄바꿈 무관

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:25:55 +09:00

151 lines
5.4 KiB
TypeScript

/**
* 글 테두리 한 벌(487 · 요구 280-6) — 스침 · 고름 테두리(`TextEntity.draw`) · 더블클릭 입력 칸
* (`integration/text-inplace-edit.ts`) · 잡는 범위(`TextEntity.distanceTo`)가 같은 셈을 씀.
* 테두리 = 글 방향 축(u 오른쪽 · v 위) · 첫 줄 기준점 = 0 · 단위 = 화면 px.
* 정렬(왼 · 가운데 · 오른) · 가운데 맞춤(textBaseline middle) · 실제 글꼴 폭 · 회전 · 여러 줄 묶음 · 도각 칸.
* `TextEntity.getBoundingBox` 는 안 씀(줄맞춤 · 그립 · 공간 색인 · 창 고르기 몫).
*/
import { Point, Segment } from '@flatten-js/core';
import { type Entity, EntityName } from '../entities/Entity';
import type { TextEntity, TextOptions } from '../entities/TextEntity';
/** 양옆 여유(× 글 크기) — 옛 입력 칸 폭 여유 1.5 를 반씩 */
export const TEXT_FRAME_PAD = 0.75;
/** 한 줄 높이(× 글 크기) */
export const TEXT_LINE_RATIO = 1.2;
/** 화면 글 폭(px) — 그리기와 같은 글꼴(굵게 · 기울임 포함)로 잼 */
export type MeasureText = (
label: string,
fontPx: number,
font: Pick<TextOptions, 'fontFamily' | 'bold' | 'italic'>
) => number;
export interface TextFrame {
left: number;
right: number;
top: number;
bottom: number;
/** 양옆 여유(px) — 입력 칸 안쪽 여백 */
pad: number;
fontPx: number;
linePx: number;
}
const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point;
let groupMemo: { entities: Entity[]; groups: Map<string, Entity[]> } | null = null;
const groupOf = (entities: Entity[], groupId: string): Entity[] => {
if (groupMemo?.entities !== entities) {
const groups = new Map<string, Entity[]>();
for (const entity of entities) {
if (!entity.groupId) continue;
const members = groups.get(entity.groupId);
if (members) members.push(entity);
else groups.set(entity.groupId, [entity]);
}
groupMemo = { entities, groups };
}
return groupMemo.groups.get(groupId) ?? [];
};
/** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */
export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] {
if (!target.groupId) return [target];
const group = groupOf(entities, target.groupId);
const x = basePointOf(target).x;
const isMultiline =
group.length > 1 &&
group.every(
(entity) =>
entity.getType() === EntityName.Text &&
Math.abs(basePointOf(entity as TextEntity).x - x) < 1e-6
);
if (!isMultiline) return [target];
return ([...group] as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y);
}
/** 글 묶음 테두리(px) — rows = 보일 글(입력 칸은 친 글) · scale = 화면 px / 월드 · minFontPx = 입력 칸 최소 글 크기 */
export function textFrame(
lines: TextEntity[],
rows: string[],
scale: number,
measure: MeasureText,
minFontPx = 0
): TextFrame {
const options = lines[0].getTextOptions();
const fontPx = Math.max(options.fontSize * scale, minFontPx);
if (options.boxWidth && options.boxHeight) {
// 도각 자리표 = 칸이 곧 테두리(기준점 = 칸 한가운데)
const width = options.boxWidth * scale;
const height = options.boxHeight * scale;
return {
left: -width / 2,
right: width / 2,
top: height / 2,
bottom: -height / 2,
pad: 0,
fontPx,
linePx: height,
};
}
const linePx =
lines.length > 1
? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx)
: fontPx * TEXT_LINE_RATIO;
const widest = Math.max(0, ...rows.map((row) => measure(row, fontPx, options)));
const pad = fontPx * TEXT_FRAME_PAD;
const start =
options.textAlign === 'left' ? 0 : options.textAlign === 'right' ? -widest : -widest / 2;
return {
left: start - pad,
right: start + widest + pad,
top: linePx / 2,
bottom: linePx / 2 - linePx * Math.max(rows.length, 1),
pad,
fontPx,
linePx,
};
}
/** 글 방향 단위 벡터(월드 · 위 +y) */
const axesOf = (entity: TextEntity): { ux: number; uy: number } => {
const { x, y } = entity.getTextOptions().textDirection;
const length = Math.hypot(x, y) || 1;
return { ux: x / length, uy: y / length };
};
/** 테두리 네 귀(월드) — 왼위 · 오른위 · 오른아래 · 왼아래 */
export function textFrameCorners(lines: TextEntity[], frame: TextFrame, scale: number): Point[] {
const base = basePointOf(lines[0]);
const { ux, uy } = axesOf(lines[0]);
const at = (u: number, v: number) =>
new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale);
return [
at(frame.left, frame.top),
at(frame.right, frame.top),
at(frame.right, frame.bottom),
at(frame.left, frame.bottom),
];
}
/** 점(월드)과 테두리 거리(월드 · 안 = 0) · 테두리 위 가장 가까운 점까지 선분 */
export function textFrameDistance(
lines: TextEntity[],
frame: TextFrame,
scale: number,
point: Point
): [number, Segment] {
const base = basePointOf(lines[0]);
const { ux, uy } = axesOf(lines[0]);
const dx = (point.x - base.x) * scale;
const dy = (point.y - base.y) * scale;
const u = Math.min(Math.max(dx * ux + dy * uy, frame.left), frame.right);
const v = Math.min(Math.max(-dx * uy + dy * ux, frame.bottom), frame.top);
const nearest = new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale);
return [
Math.hypot(point.x - nearest.x, point.y - nearest.y),
new Segment(new Point(point.x, point.y), nearest),
];
}