@@ -0,0 +1,381 @@
/**
* 고른 범위 → 표 자료(M02 83장 · 도면 → 면적산출 한 번 옮기기). 상태 없는 순수 함수 — 엔티티 JSON(`toJson` 꼴)만 읽는다.
* ① 표 엔티티(Table) 칸 그대로(병합 = colSpan · rowSpan · 폭 · 높이)
* ② 선 + 글 격자 인식 — 가로 · 세로 선 경계를 허용오차로 묶고(PolyLine 자식 · 사각형 변 풀어 씀 ·
* 끊긴 · 겹친 선 합침) 안쪽 선 빠진 곳 = 병합 · 글 자리가 든 칸에 글
* ③ 둘 다 아님 글 자리로 줄(y 묶음) · 칸(x 차례) 폴백
* 「1,500」 같은 글은 숫자로. 선이 축에서 기운 것(회전된 표)은 ③ 으로 떨어진다.
* 칸 기하는 `table-geometry.ts`(cellOwners) 와 같은 규칙 — 서버 짝 판정은 `Engine_Frame_Export.table_parts`.
* 시험: `resources/tester/cad/table-from-drawing.test.ts`(node --experimental-strip-types).
*/
import { cellOwners , type TableCells } from './table-geometry.ts' ;
export type DrawingCellValue = string | number ;
export interface DrawingTableMerge {
row : number ;
col : number ;
rowSpan : number ;
colSpan : number ;
}
export interface DrawingTable {
/** table = 표 엔티티 · grid = 선 격자 · text = 글 자리 폴백 */
kind : 'table' | 'grid' | 'text' ;
rows : DrawingCellValue [ ] [ ] ;
merges : DrawingTableMerge [ ] ;
/** 도면 단위(mm) — 폴백(text)은 빈 배열(기본 폭) */
colWidths : number [ ] ;
rowHeights : number [ ] ;
}
/** 엔티티 JSON 중 읽는 칸만 */
export interface DrawingEntityJson {
type : string ;
// biome-ignore lint/suspicious/noExplicitAny: 엔티티 종류마다 꼴이 달라 읽는 자리에서 가림
shapeData? : any ;
children? : DrawingEntityJson [ ] | null ;
}
interface Seg {
x1 : number ;
y1 : number ;
x2 : number ;
y2 : number ;
}
interface Label {
text : string ;
x : number ;
y : number ;
size : number ;
}
interface Interval {
at : number ;
from : number ;
to : number ;
}
const NUMBER = /^[+\-− ]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$|^[+\-− ]?\.\d+$/ ;
/** 「1,500」 · 「-2.5」 → 숫자 · 그 밖은 글 그대로(앞뒤 공백만 뗌) */
export function cellValue ( text : string ) : DrawingCellValue {
const trimmed = text . trim ( ) ;
if ( ! NUMBER . test ( trimmed ) ) return trimmed ;
const value = Number ( trimmed . replace ( /,/g , '' ) . replace ( '− ' , '-' ) ) ;
return Number . isFinite ( value ) ? value : trimmed ;
}
const span = ( value : number | undefined ) : number = > Math . max ( 1 , Math . round ( value ? ? 1 ) ) ;
/** ① 표 엔티티 → 표 자료 */
function fromTableEntity ( data : {
columnWidths : number [ ] ;
rowHeights : number [ ] ;
cells : TableCells ;
} ) : DrawingTable {
const rowCount = data . rowHeights . length ;
const colCount = data . columnWidths . length ;
const rows : DrawingCellValue [ ] [ ] = [ ] ;
const merges : DrawingTableMerge [ ] = [ ] ;
for ( let row = 0 ; row < rowCount ; row += 1 ) {
const line : DrawingCellValue [ ] = [ ] ;
for ( let col = 0 ; col < colCount ; col += 1 ) {
const cell = data . cells [ row ] ? . [ col ] ;
line . push ( cell ? cellValue ( cell . text ? ? '' ) : '' ) ;
if ( ! cell ) continue ;
const rowSpan = Math . min ( span ( cell . rowSpan ) , rowCount - row ) ;
const colSpan = Math . min ( span ( cell . colSpan ) , colCount - col ) ;
if ( rowSpan > 1 || colSpan > 1 ) merges . push ( { row , col , rowSpan , colSpan } ) ;
}
rows . push ( line ) ;
}
return {
kind : 'table' ,
rows ,
merges ,
colWidths : [ . . . data . columnWidths ] ,
rowHeights : [ . . . data . rowHeights ] ,
} ;
}
/** 선 · 글 모으기 — PolyLine 자식 · 사각형 변을 풀어 씀 */
function collect ( entities : DrawingEntityJson [ ] , segs : Seg [ ] , labels : Label [ ] ) : void {
for ( const entity of entities ) {
const data = entity . shapeData ;
if ( entity . type === 'Line' && data ? . startPoint && data ? . endPoint ) {
segs . push ( {
x1 : data.startPoint.x ,
y1 : data.startPoint.y ,
x2 : data.endPoint.x ,
y2 : data.endPoint.y ,
} ) ;
} else if ( entity . type === 'Rectangle' && Array . isArray ( data ? . points ) ) {
const points = data . points as { x : number ; y : number } [ ] ;
for ( let index = 0 ; index < points . length ; index += 1 ) {
const a = points [ index ] ;
const b = points [ ( index + 1 ) % points . length ] ;
segs . push ( { x1 : a.x , y1 : a.y , x2 : b.x , y2 : b.y } ) ;
}
} else if ( entity . type === 'PolyLine' && entity . children ) {
collect ( entity . children , segs , labels ) ;
} else if ( entity . type === 'Text' && data ? . basePoint && String ( data . label ? ? '' ) . trim ( ) ) {
labels . push ( labelOf ( data ) ) ;
}
}
}
/** 글이 놓인 자리 — 자리표 칸이면 basePoint 가 가운데 · 아니면 기준선에서 글 높이 반쯤 위 */
function labelOf ( data : {
label : string ;
basePoint : { x : number ; y : number } ;
options ? : { fontSize? : number ; boxWidth? : number ; boxHeight? : number } ;
} ) : Label {
const size = data . options ? . fontSize ? ? 2.2 ;
const boxed = Boolean ( data . options ? . boxWidth && data . options ? . boxHeight ) ;
return {
text : data.label ,
x : data.basePoint.x ,
y : boxed ? data.basePoint.y : data.basePoint.y + size * 0.35 ,
size ,
} ;
}
/** 값들을 허용오차 안에서 묶어 대표값(평균) 목록 — 오름차순 */
function cluster ( values : number [ ] , tol : number ) : number [ ] {
const sorted = [ . . . values ] . sort ( ( a , b ) = > a - b ) ;
const groups : number [ ] [ ] = [ ] ;
for ( const value of sorted ) {
const last = groups [ groups . length - 1 ] ;
if ( last && value - last [ last . length - 1 ] <= tol ) last . push ( value ) ;
else groups . push ( [ value ] ) ;
}
return groups . map ( ( group ) = > group . reduce ( ( sum , value ) = > sum + value , 0 ) / group . length ) ;
}
const nearest = ( edges : number [ ] , value : number , tol : number ) : number = > {
let best = - 1 ;
let bestGap = tol ;
for ( let index = 0 ; index < edges . length ; index += 1 ) {
const gap = Math . abs ( edges [ index ] - value ) ;
if ( gap <= bestGap ) {
best = index ;
bestGap = gap ;
}
}
return best ;
} ;
/** 한 경계 위 선 구간들이 [from, to] 를 거의(90%) 덮는가 — 끊긴 · 겹친 선은 합쳐 셈 */
function covers ( intervals : Interval [ ] , from : number , to : number , tol : number ) : boolean {
const parts = intervals
. map ( ( part ) = > [ Math . max ( part . from , from ) , Math . min ( part . to , to ) ] as const )
. filter ( ( [ a , b ] ) = > b > a )
. sort ( ( p , q ) = > p [ 0 ] - q [ 0 ] ) ;
let covered = 0 ;
let reach = from ;
for ( const [ a , b ] of parts ) {
const start = Math . max ( a , reach ) ;
if ( b > start ) covered += b - start ;
reach = Math . max ( reach , b ) ;
}
return covered >= ( to - from ) * 0.9 - tol ;
}
/** ② 선 + 글 격자 — 경계가 2×2 안 되거나 글이 반 넘게 격자 밖이면 null */
function fromGrid ( segs : Seg [ ] , labels : Label [ ] ) : DrawingTable | null {
const box = { x0 : Infinity , x1 : - Infinity , y0 : Infinity , y1 : - Infinity } ;
for ( const s of segs ) {
box . x0 = Math . min ( box . x0 , s . x1 , s . x2 ) ;
box . x1 = Math . max ( box . x1 , s . x1 , s . x2 ) ;
box . y0 = Math . min ( box . y0 , s . y1 , s . y2 ) ;
box . y1 = Math . max ( box . y1 , s . y1 , s . y2 ) ;
}
const extent = Math . max ( box . x1 - box . x0 , box . y1 - box . y0 , 1 e - 9 ) ;
const tol = extent * 0.003 ;
const horizontal : Seg [ ] = [ ] ;
const vertical : Seg [ ] = [ ] ;
for ( const s of segs ) {
const dx = Math . abs ( s . x2 - s . x1 ) ;
const dy = Math . abs ( s . y2 - s . y1 ) ;
if ( Math . max ( dx , dy ) <= tol ) continue ; // 점 같은 선
if ( dy <= dx * 0.01 ) horizontal . push ( s ) ;
else if ( dx <= dy * 0.01 ) vertical . push ( s ) ;
}
let colEdges = cluster (
vertical . map ( ( s ) = > ( s . x1 + s . x2 ) / 2 ) ,
tol
) ;
let rowEdges = cluster (
horizontal . map ( ( s ) = > ( s . y1 + s . y2 ) / 2 ) ,
tol
) . reverse ( ) ; // 위 → 아래
const lineOf = ( list : Seg [ ] , edges : number [ ] , vertical : boolean ) : Interval [ ] [ ] = > {
const byEdge : Interval [ ] [ ] = edges . map ( ( ) = > [ ] ) ;
for ( const s of list ) {
const at = vertical ? ( s . x1 + s . x2 ) / 2 : ( s . y1 + s . y2 ) / 2 ;
const index = nearest ( edges , at , tol ) ;
if ( index < 0 ) continue ;
const [ a , b ] = vertical ? [ s . y1 , s . y2 ] : [ s . x1 , s . x2 ] ;
byEdge [ index ] . push ( { at , from : Math . min ( a , b ) , to : Math.max ( a , b ) } ) ;
}
return byEdge ;
} ;
// 칸 경계를 하나도 덮지 못하는 짧은 선(눈금 · 지시선)의 경계는 뺀다 — 두 번이면 자리 잡음
for ( let pass = 0 ; pass < 2 ; pass += 1 ) {
if ( colEdges . length < 2 || rowEdges . length < 2 ) return null ;
const vLines = lineOf ( vertical , colEdges , true ) ;
const hLines = lineOf ( horizontal , rowEdges , false ) ;
const keepCol = colEdges . map ( ( _ , i ) = >
rowEdges . slice ( 1 ) . some ( ( y , j ) = > covers ( vLines [ i ] , y + tol , rowEdges [ j ] - tol , tol ) )
) ;
const keepRow = rowEdges . map ( ( _ , j ) = >
colEdges . slice ( 1 ) . some ( ( x , i ) = > covers ( hLines [ j ] , colEdges [ i ] + tol , x - tol , tol ) )
) ;
colEdges = colEdges . filter ( ( _ , i ) = > keepCol [ i ] ) ;
rowEdges = rowEdges . filter ( ( _ , j ) = > keepRow [ j ] ) ;
}
if ( colEdges . length < 2 || rowEdges . length < 2 ) return null ;
const colCount = colEdges . length - 1 ;
const rowCount = rowEdges . length - 1 ;
const vLines = lineOf ( vertical , colEdges , true ) ;
const hLines = lineOf ( horizontal , rowEdges , false ) ;
// 안쪽 선 빠진 곳 = 같은 묶음(union-find)
const parent = Array . from ( { length : rowCount * colCount } , ( _ , i ) = > i ) ;
const find = ( i : number ) : number = > {
while ( parent [ i ] !== i ) {
parent [ i ] = parent [ parent [ i ] ] ;
i = parent [ i ] ;
}
return i ;
} ;
const join = ( a : number , b : number ) : void = > {
parent [ find ( a ) ] = find ( b ) ;
} ;
for ( let r = 0 ; r < rowCount ; r += 1 ) {
for ( let c = 1 ; c < colCount ; c += 1 ) {
if ( ! covers ( vLines [ c ] , rowEdges [ r + 1 ] + tol , rowEdges [ r ] - tol , tol ) )
join ( r * colCount + c - 1 , r * colCount + c ) ;
}
}
for ( let r = 1 ; r < rowCount ; r += 1 ) {
for ( let c = 0 ; c < colCount ; c += 1 ) {
if ( ! covers ( hLines [ r ] , colEdges [ c ] + tol , colEdges [ c + 1 ] - tol , tol ) )
join ( ( r - 1 ) * colCount + c , r * colCount + c ) ;
}
}
// 묶음이 꽉 찬 사각형이면 병합 — 아니면(ㄱ 자 등) 칸 따로
const groups = new Map < number , number [ ] > ( ) ;
for ( let i = 0 ; i < rowCount * colCount ; i += 1 ) {
const root = find ( i ) ;
const members = groups . get ( root ) ;
if ( members ) members . push ( i ) ;
else groups . set ( root , [ i ] ) ;
}
const cells : TableCells = Array . from ( { length : rowCount } , ( ) = >
Array . from ( { length : colCount } , ( ) = > ( { text : '' } ) )
) ;
for ( const members of groups . values ( ) ) {
if ( members . length < 2 ) continue ;
const rs = members . map ( ( i ) = > Math . floor ( i / colCount ) ) ;
const cs = members . map ( ( i ) = > i % colCount ) ;
const [ r0 , r1 , c0 , c1 ] = [ Math . min ( . . . rs ) , Math . max ( . . . rs ) , Math . min ( . . . cs ) , Math . max ( . . . cs ) ] ;
if ( ( r1 - r0 + 1 ) * ( c1 - c0 + 1 ) !== members . length ) continue ;
cells [ r0 ] [ c0 ] = { text : '' , rowSpan : r1 - r0 + 1 , colSpan : c1 - c0 + 1 } ;
for ( let r = r0 ; r <= r1 ; r += 1 )
for ( let c = c0 ; c <= c1 ; c += 1 ) if ( r !== r0 || c !== c0 ) cells [ r ] [ c ] = null ;
}
// 글 → 칸(병합이면 앵커) · 한 칸 여러 글은 위 → 아래 · 왼 → 오른 차례로 띄어 이음
const owners = cellOwners ( cells , rowCount , colCount ) ;
const texts = new Map < string , Label [ ] > ( ) ;
let outside = 0 ;
for ( const label of labels ) {
const c = colEdges . findIndex (
( x , i ) = > i < colCount && label . x >= x && label . x <= colEdges [ i + 1 ]
) ;
const r = rowEdges . findIndex (
( y , j ) = > j < rowCount && label . y <= y && label . y >= rowEdges [ j + 1 ]
) ;
if ( c < 0 || r < 0 ) {
outside += 1 ;
continue ;
}
const key = owners [ r ] [ c ] ? ? ` ${ r } : ${ c } ` ;
const inCell = texts . get ( key ) ;
if ( inCell ) inCell . push ( label ) ;
else texts . set ( key , [ label ] ) ;
}
if ( ! labels . length || outside * 2 > labels . length ) return null ;
const rows : DrawingCellValue [ ] [ ] = cells . map ( ( line ) = > line . map ( ( ) = > '' ) ) ;
const merges : DrawingTableMerge [ ] = [ ] ;
cells . forEach ( ( line , r ) = >
line . forEach ( ( cell , c ) = > {
if ( ! cell ) return ;
if ( ( cell . rowSpan ? ? 1 ) > 1 || ( cell . colSpan ? ? 1 ) > 1 )
merges . push ( { row : r , col : c , rowSpan : cell.rowSpan ? ? 1 , colSpan : cell.colSpan ? ? 1 } ) ;
const inCell = texts . get ( ` ${ r } : ${ c } ` ) ? ? [ ] ;
const sorted = sortReading ( inCell ) ;
if ( sorted . length ) rows [ r ] [ c ] = cellValue ( sorted . map ( ( label ) = > label . text . trim ( ) ) . join ( ' ' ) ) ;
} )
) ;
return {
kind : 'grid' ,
rows ,
merges ,
colWidths : colEdges.slice ( 1 ) . map ( ( x , i ) = > x - colEdges [ i ] ) ,
rowHeights : rowEdges.slice ( 1 ) . map ( ( y , j ) = > rowEdges [ j ] - y ) ,
} ;
}
/** 읽는 차례 — 줄(y, 글 높이 반 안이면 같은 줄) 위 → 아래 · 줄 안 왼 → 오른 */
function textLines ( labels : Label [ ] ) : Label [ ] [ ] {
const sorted = [ . . . labels ] . sort ( ( a , b ) = > b . y - a . y ) ;
const lines : { y : number ; size : number ; items : Label [ ] } [ ] = [ ] ;
for ( const label of sorted ) {
const last = lines [ lines . length - 1 ] ;
if ( last && last . y - label . y <= Math . max ( last . size , label . size ) * 0.5 ) last . items . push ( label ) ;
else lines . push ( { y : label.y , size : label.size , items : [ label ] } ) ;
}
return lines . map ( ( line ) = > line . items . sort ( ( a , b ) = > a . x - b . x ) ) ;
}
const sortReading = ( labels : Label [ ] ) : Label [ ] = > textLines ( labels ) . flat ( ) ;
/** ③ 글 자리 폴백 — 줄마다 x 차례로 칸 */
function fromText ( labels : Label [ ] ) : DrawingTable {
const lines = textLines ( labels ) ;
const width = Math . max ( . . . lines . map ( ( line ) = > line . length ) ) ;
return {
kind : 'text' ,
rows : lines.map ( ( line ) = > {
const values : DrawingCellValue [ ] = line . map ( ( label ) = > cellValue ( label . text ) ) ;
while ( values . length < width ) values . push ( '' ) ;
return values ;
} ) ,
merges : [ ] ,
colWidths : [ ] ,
rowHeights : [ ] ,
} ;
}
/**
* 고른 엔티티 JSON → 표 자료. 표 엔티티가 있으면 칸이 가장 많은 표 하나 · 없으면 선 격자 · 그래도 아니면 글 자리.
* 표로 옮길 글이 하나도 없으면(빈 범위 · 글 없는 선) null.
*/
export function tableFromDrawing ( entities : DrawingEntityJson [ ] ) : DrawingTable | null {
const tables = entities . filter (
( entity ) = > entity . type === 'Table' && entity . shapeData ? . columnWidths ? . length
) ;
if ( tables . length ) {
const size = ( entity : DrawingEntityJson ) = >
entity . shapeData . columnWidths . length * entity . shapeData . rowHeights . length ;
const biggest = tables . reduce ( ( best , entity ) = > ( size ( entity ) > size ( best ) ? entity : best ) ) ;
return fromTableEntity ( biggest . shapeData ) ;
}
const segs : Seg [ ] = [ ] ;
const labels : Label [ ] = [ ] ;
collect ( entities , segs , labels ) ;
if ( ! labels . length ) return null ;
return ( segs . length ? fromGrid ( segs , labels ) : null ) ? ? fromText ( labels ) ;
}