Files
Pier_v1/도면작성/projection.ts
T
ypshim f51865c57b Merge origin/master with current Pierzainer development
Preserve local abutment, cast-in-place rebar and compact header features alongside remote drawing, ground-link and quantity workspaces. Adapt drawing and takeoff consumers to generated solplate plans, retain current P1 regression coverage, and resolve bearing-detail annotation collisions.

Validation: 122 test files / 975 tests passed; production build passed; 10 browser integration checks passed.
2026-11-06 17:10:33 +09:00

147 lines
9.3 KiB
TypeScript

import { Vector3, type Plane } from 'three';
import { DIM, blockFramePoint, type PierModel } from '../src/core/geometry';
import { INTEGRATED_BLOCK } from '../src/core/integrated-block';
import { solplateGeometry } from '../src/core/solplate';
import { stretchMatrix, mitrePlane } from '../src/view/scene';
import type { Bounds, Entity, Layer, Point, Source, ViewId } from './types';
type Face = Vector3[];
type Solid = { faces: Face[]; source: Source; layer: Layer };
export type Cylinder = { center: Vector3; bottom: number; top: number; radius: number; source: Source };
const EPS = .001; // mm, drawing topology tolerance; source dimensions are never rounded here.
const cross = (a: Point, b: Point) => a[0]*b[1]-a[1]*b[0];
const sub = (a: Point, b: Point): Point => [a[0]-b[0],a[1]-b[1]];
const key = (p: Point) => `${Math.round(p[0]/EPS)},${Math.round(p[1]/EPS)}`;
const edgeKey = (a: Point,b: Point) => [key(a),key(b)].sort().join('|');
function clip(face: Face, plane?: Plane): Face {
if (!plane) return face;
const out: Face=[];
for (let i=0;i<face.length;i++) {
const a=face[i],b=face[(i+1)%face.length],da=plane.distanceToPoint(a),db=plane.distanceToPoint(b);
if (da>=-1e-9) out.push(a);
if ((da>=0)!==(db>=0)) out.push(a.clone().lerp(b,da/(da-db)));
}
return out;
}
export function localPoint(model: PierModel,k: number,s: number,z: number,n: number): Vector3 {
return new Vector3(s,z,n).applyMatrix4(stretchMatrix(model,k));
}
export function projectPoint(model: PierModel,point: Vector3,view: ViewId): Point {
const east=point.x+model.pivot.e-model.bp1.e,north=-point.z+model.pivot.n-model.bp1.n;
const along=(east*model.u.e+north*model.u.n)*1000;
const across=(east*model.nrm.e+north*model.nrm.n)*1000;
return view==='front' ? [along,point.y*1000] : view==='side' ? [across,point.y*1000] : [along,across];
}
function area(p: Point[]): number { return p.reduce((sum,a,i)=>sum+cross(a,p[(i+1)%p.length]),0)/2; }
function inside(p: Point,poly: Point[]): boolean {
let hit=false;
for(let i=0,j=poly.length-1;i<poly.length;j=i++) {
const a=poly[i],b=poly[j];
if((a[1]>p[1])!==(b[1]>p[1]) && p[0]<(b[0]-a[0])*(p[1]-a[1])/(b[1]-a[1])+a[0]) hit=!hit;
}
return hit;
}
/** Boundary of the union of orthographically projected solid faces (no screenshot or mesh raster). */
export function silhouette(polygons: Point[][]): [Point,Point][] {
const valid=polygons.filter(p=>p.length>=3&&Math.abs(area(p))>EPS*EPS);
const edges=valid.flatMap(p=>p.map((a,i)=>[a,p[(i+1)%p.length]] as [Point,Point]));
const result=new Map<string,[Point,Point]>();
for(const [a,b] of edges) {
const d=sub(b,a),length=Math.hypot(...d); if(length<EPS)continue;
const ts=[0,1];
for(const [c,e] of edges) {
const r=sub(e,c),q=sub(c,a),det=cross(d,r);
if(Math.abs(det)>1e-8) {
const t=cross(q,r)/det,u=cross(q,d)/det;
if(t>0&&t<1&&u>=-1e-9&&u<=1+1e-9)ts.push(t);
} else if(Math.abs(cross(q,d))<EPS*length) {
for(const p of [c,e]) { const v=sub(p,a),t=(v[0]*d[0]+v[1]*d[1])/(length*length);if(t>0&&t<1)ts.push(t); }
}
}
const sorted=[...new Set(ts.map(t=>Math.round(t*1e10)/1e10))].sort((x,y)=>x-y);
const at=(t: number): Point=>[a[0]+d[0]*t,a[1]+d[1]*t];
for(let i=1;i<sorted.length;i++) {
const start=at(sorted[i-1]),end=at(sorted[i]);if(Math.hypot(...sub(end,start))<EPS)continue;
const middle=at((sorted[i-1]+sorted[i])/2),offset=EPS*.2;
const left: Point=[middle[0]-d[1]/length*offset,middle[1]+d[0]/length*offset];
const right: Point=[middle[0]+d[1]/length*offset,middle[1]-d[0]/length*offset];
if(valid.some(p=>inside(left,p))!==valid.some(p=>inside(right,p)))result.set(edgeKey(start,end),[start,end]);
}
}
return [...result.values()];
}
function prism(model: PierModel,k: number,profile: Point[],n0: number,n1: number,source: Source,layer: Layer,cut=true): Solid {
const front=profile.map(([s,z])=>localPoint(model,k,s,z,n0));
const back=profile.map(([s,z])=>localPoint(model,k,s,z,n1));
const faces=[front,[...back].reverse(),...front.map((a,i)=>[a,front[(i+1)%front.length],back[(i+1)%back.length],back[i]])];
return { faces:faces.map(f=>clip(f,cut?mitrePlane(model,k):undefined)).filter(f=>f.length>=3),source,layer };
}
function box(model: PierModel,k: number,s: number,n: number,length: number,width: number,bottom: number,top: number,source: Source,layer: Layer,skew=0,cut=false): Solid {
const corners=[[-length/2,-width/2],[length/2,-width/2],[length/2,width/2],[-length/2,width/2]].map(([a,c])=>blockFramePoint({s,n,rowSkewDeg:skew},a,c));
const low=corners.map(([a,c])=>localPoint(model,k,a,bottom,c));
const high=corners.map(([a,c])=>localPoint(model,k,a,top,c));
return {source,layer,faces:[[...low].reverse(),high,...low.map((a,i)=>[a,low[(i+1)%4],high[(i+1)%4],high[i]])].map(f=>clip(f,cut?mitrePlane(model,k):undefined)).filter(f=>f.length>=3)};
}
/** Derived, disposable drawing primitives; the supplied PierModel remains the only geometry source. */
export function modelPrimitives(model: PierModel): { solids: Solid[]; cylinders: Cylinder[] } {
const solids: Solid[]=[],cylinders: Cylinder[]=[];
const source=(member: string,index: number): Source=>({member,index,pier:model.name});
model.copingParts.forEach((p,i)=>solids.push(prism(model,p.k,p.profile,p.nFrom,p.nTo,source('coping',i),'STRUCTURE')));
model.footings.forEach((f,i)=>solids.push(box(model,f.k,f.s,0,f.length,f.width,f.bottom,f.top,source('footing',i),'STRUCTURE',0,true)));
model.blindings.forEach((b,i)=>solids.push(box(model,b.k,b.s,0,b.length,b.width,b.top-b.thickness,b.top,source('blinding',i),'STRUCTURE',0,true)));
model.columns.forEach((c,i)=>cylinders.push({center:localPoint(model,c.k,c.s,0,0),bottom:c.bottom,top:c.top,radius:c.diameter/2,source:source('column',i)}));
model.piles.forEach((p,i)=>cylinders.push({center:localPoint(model,p.k,p.s,0,p.n),bottom:p.bottom,top:p.top,radius:p.diameter/2,source:source('pile',i)}));
model.blocks.forEach((b,i)=>{
if(!model.integratedBlocks.length)solids.push(box(model,b.k,b.s,b.n,DIM.blockSize,DIM.blockSize,b.bottom,b.top,source('block',i),'BEARING',b.rowSkewDeg));
solids.push(box(model,b.k,b.s,b.n,DIM.padSize,DIM.padSize,b.padTop-DIM.padThickness,b.padTop,source('pad',i),'BEARING',b.rowSkewDeg));
if(b.plan) {
solids.push(box(model,b.k,b.s,b.n,b.plan.W/1000-.2,b.plan.L/1000-.2,b.padTop,b.padTop+b.plan.h3/1000,source('bearing',i),'BEARING',b.rowSkewDeg));
const corners=solplateGeometry(b,b.plan).corners.map(([s,z,n])=>localPoint(model,b.k,s,z,n));
const faces=[[0,4,6,2],[1,3,7,5],[0,1,5,4],[2,6,7,3],[0,2,3,1],[4,5,7,6]].map(indices=>indices.map(index=>corners[index]));
solids.push({source:source('solplate',i),layer:'BEARING',faces});
}
});
model.integratedBlocks.forEach((b,i)=>{
const k=model.blocks.find(p=>p.no===b.frontNo&&p.row==='전열')!.k;
for(const [top,n] of [[b.topFront,b.n-INTEGRATED_BLOCK.length/4000],[b.topBack,b.n+INTEGRATED_BLOCK.length/4000]])
solids.push(box(model,k,b.s,n,INTEGRATED_BLOCK.depth/1000,INTEGRATED_BLOCK.length/2000,b.bottom,top,source('integrated-block',i),'BEARING',b.rowSkewDeg));
});
return {solids,cylinders};
}
export function projectModel(model: PierModel,view: ViewId,primitives=modelPrimitives(model)): Entity[] {
const entities: Entity[]=[];
const footingOnly=view==='footing';
for(const solid of primitives.solids) {
if(footingOnly&&!['footing','blinding'].includes(solid.source.member))continue;
if(view==='top'&&['footing','blinding'].includes(solid.source.member))continue;
const polygons=solid.faces.map(face=>face.map(p=>projectPoint(model,p,view)));
const edges=silhouette(polygons),outer=new Set(edges.map(([a,b])=>edgeKey(a,b)));
for(const [a,b] of edges)entities.push({kind:'line',a,b,layer:solid.layer,source:solid.source});
const hidden=new Set<string>();
for(const poly of polygons)for(let i=0;i<poly.length;i++) {
const a=poly[i],b=poly[(i+1)%poly.length],id=edgeKey(a,b);
if(!outer.has(id)&&!hidden.has(id)&&Math.hypot(...sub(a,b))>EPS) { hidden.add(id); entities.push({kind:'line',a,b,layer:'HIDDEN',source:solid.source}); }
}
}
for(const cylinder of primitives.cylinders) {
if(view==='top'&&cylinder.source.member==='pile')continue;
const center=projectPoint(model,cylinder.center,view),r=cylinder.radius*1000;
if(view==='top'||view==='footing')entities.push({kind:'circle',center,radius:r,layer:cylinder.source.member==='pile'?'HIDDEN':'STRUCTURE',source:cylinder.source});
else {
const bottom=cylinder.bottom*1000,top=cylinder.top*1000;
entities.push({kind:'polyline',points:[[center[0]-r,bottom],[center[0]+r,bottom],[center[0]+r,top],[center[0]-r,top]],closed:true,layer:'STRUCTURE',source:cylinder.source});
}
}
return entities;
}
export function entityBounds(entities: Entity[]): Bounds {
const points: Point[]=entities.flatMap(e=>e.kind==='line'?[e.a,e.b]:e.kind==='polyline'||e.kind==='hatch'?e.points:e.kind==='circle'?[[e.center[0]-e.radius,e.center[1]-e.radius],[e.center[0]+e.radius,e.center[1]+e.radius]] as Point[]:e.kind==='dimension'?(e.axis==='x'?[[e.a[0],e.position],[e.b[0],e.position],e.a,e.b]:[[e.position,e.a[1]],[e.position,e.b[1]],e.a,e.b]) as Point[]:[e.point]);
if(!points.length)return{minX:0,minY:0,maxX:1,maxY:1};
return{minX:Math.min(...points.map(p=>p[0])),minY:Math.min(...points.map(p=>p[1])),maxX:Math.max(...points.map(p=>p[0])),maxY:Math.max(...points.map(p=>p[1]))};
}