import * as THREE from 'three'; import type {Vec2} from '../../geometry/Vec2'; import {createKnife,roundedRectShape} from '../../cutting/KnifeGeometry'; import {createEndlessSolid,solidVolume,splitSolid,type Solid,type SolidChild} from './EndlessSolid'; import {EndlessBatch,endlessGeometry,softenMatrix,type BatchPiece} from './EndlessMesh'; import {EndlessIndex} from './EndlessIndex'; import {ENDLESS_SOFT,softPress,softSpring,type SoftPose} from './EndlessSoft'; import {EndlessFall,EndlessFallShape,ENDLESS_FALL} from './EndlessFall'; interface Piece extends BatchPiece { solid:Solid;position:THREE.Vector3;rotation:THREE.Quaternion;scale:number;volume:number;bounds:THREE.Box3; visualMatrix:THREE.Matrix4;softPose:SoftPose;softX:number;softZ:number; fallShape:EndlessFallShape; } interface Job {source:Piece;plane:THREE.Plane;children:Piece[];split?:[SolidChild,SolidChild];pausedMotion?:Motion} interface Cut {jobs:Job[];plane:THREE.Plane;a:Vec2;b:Vec2;elapsed:number;committed:boolean} interface Motion {piece:Piece;fall:EndlessFall;outward:Vec2} interface SoftMotion {piece:Piece;time:number;amplitude:number;dirX:number;dirZ:number} export interface EndlessStats {pieces:number;cuts:number;busy:boolean;prepared:number;meshBuilds:number;lastFrameBuilds:number;inputMeshBuilds:number;volume:number;rejected:number;visiblePieces:number;vertices:number;prepMaxMs:number;updateMaxMs:number;softPieces:number;maxSquash:number;maxShear:number;fallingPieces:number;landings:number;maxClearance:number} /** 입체 형상·프레임별 준비·원자적 인계를 소유하며 입력에서는 메시를 만들지 않는다. */ export class EndlessScene { readonly group=new THREE.Group(); readonly batch=new EndlessBatch(); private pieces=new Map(); private index=new EndlessIndex(); private id=0; private cuts=0; private cut:Cut|null=null; private motions=new Map(); private softMotions=new Map(); private landings=0; private knife=createKnife(2.3); private guide=new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(),new THREE.Vector3()]),new THREE.LineBasicMaterial({color:0xfff4d6,depthTest:false,transparent:true,opacity:.9})); private meshBuilds=0; private lastFrameBuilds=0; private rejected=0; private disposed=false; private prepMaxMs=0; private updateMaxMs=0; private frustum:THREE.Frustum|null=null; constructor(parent:THREE.Object3D,private readonly reducedMotion=false){ parent.add(this.group);this.group.name='endless-scene'; const board=new THREE.ExtrudeGeometry(roundedRectShape(-2.75,-2.4,2.75,2.4,.25),{depth:.12,bevelEnabled:true,bevelSize:.045,bevelThickness:.04,bevelSegments:2,curveSegments:8});board.rotateX(-Math.PI/2);board.translate(0,-.13,0); const table=new THREE.Mesh(board,new THREE.MeshPhysicalMaterial({color:0xf3dbb9,roughness:.48,clearcoat:.2}));table.receiveShadow=true;this.group.add(table,this.batch.mesh,this.knife,this.guide); this.knife.visible=this.guide.visible=false;this.guide.renderOrder=20; this.knife.name='endless-knife'; const solid=createEndlessSolid(),piece=this.makePiece(solid,new THREE.Vector3(0,.5,0),new THREE.Quaternion(),1,solidVolume(solid));this.pieces.set(piece.id,piece);this.sync(piece);this.startSoft(piece,ENDLESS_SOFT.initialAmplitude,1,.4);this.batch.rebuild(this.pieces.values()); } private makePiece(solid:Solid,position:THREE.Vector3,rotation:THREE.Quaternion,scale:number,volume:number):Piece{ this.meshBuilds++;const geometry=endlessGeometry(solid); return {id:++this.id,solid,geometry,position,rotation,scale,volume,matrix:new THREE.Matrix4(),visualMatrix:new THREE.Matrix4(),softPose:{squash:0,shear:0},softX:0,softZ:0,bounds:new THREE.Box3(),fallShape:new EndlessFallShape(solid),visible:true}; } private sync(p:Piece,exactBounds?:THREE.Box3):void{ p.matrix.compose(p.position,p.rotation,new THREE.Vector3(p.scale,p.scale,p.scale)); if(exactBounds)p.bounds.copy(exactBounds);else p.bounds.copy(p.geometry.boundingBox!).applyMatrix4(p.matrix); softenMatrix(p.matrix,p.bounds,p.softPose,p.softX,p.softZ,p.visualMatrix); this.index.set(p.id,{minX:p.bounds.min.x,maxX:p.bounds.max.x,minZ:p.bounds.min.z,maxZ:p.bounds.max.z}); } private setSoft(p:Piece,pose:SoftPose,dirX:number,dirZ:number):boolean { if(p.softPose.squash===pose.squash&&p.softPose.shear===pose.shear&&p.softX===dirX&&p.softZ===dirZ)return false; p.softPose=pose;p.softX=dirX;p.softZ=dirZ;softenMatrix(p.matrix,p.bounds,pose,dirX,dirZ,p.visualMatrix);return true; } private startSoft(p:Piece,amplitude:number,dirX:number,dirZ:number):void { if(this.reducedMotion)return; if(Math.abs(amplitude)<1e-6){ // 눌림이 0인 순간에도 전단은 남을 수 있다. 다음 프레임에 REST와 GPU 버퍼를 함께 복구한다. this.softMotions.set(p.id,{piece:p,time:ENDLESS_SOFT.duration,amplitude:0,dirX,dirZ});return; } this.softMotions.set(p.id,{piece:p,time:0,amplitude,dirX,dirZ});this.setSoft(p,softSpring(0,amplitude),dirX,dirZ); } private candidates(a:Vec2,b:Vec2):{plane:THREE.Plane;jobs:Job[]}{ const dir=new THREE.Vector3(b.x-a.x,0,b.y-a.y),length=dir.length(); if(length<1e-8)return {plane:new THREE.Plane(),jobs:[]};dir.divideScalar(length); const plane=new THREE.Plane().setFromNormalAndCoplanarPoint(new THREE.Vector3(-dir.z,0,dir.x),new THREE.Vector3(a.x,0,a.y)),jobs:Job[]=[]; for(const id of this.index.query(a,b)){ const source=this.pieces.get(id)!;const local=plane.clone().applyMatrix4(source.matrix.clone().invert()); const ds=source.solid.faces.flatMap(f=>f.vertices.map(v=>local.distanceToPoint(new THREE.Vector3(v.p.x,v.p.y,v.p.z)))); if(Math.min(...ds)>=-1e-9||Math.max(...ds)<=1e-9)continue; let min=Infinity,max=-Infinity; for(const face of source.solid.faces)for(let i=0;i0&&Math.abs(da)>1e-9)continue; if(Math.abs(da-db)>1e-12)pa.lerp(pb,Math.max(0,Math.min(1,da/(da-db)))); pa.applyMatrix4(source.matrix).sub(new THREE.Vector3(a.x,0,a.y));const t=pa.dot(dir);min=Math.min(min,t);max=Math.max(max,t); } if(min>=-1e-7&&max<=length+1e-7&&max>min)jobs.push({source,plane:local,children:[]}); } return {plane,jobs}; } /** 공간 격자로 후보를 좁힌 뒤 시각 변형까지 반영한 실제 삼각형을 고른다. */ pickHeight(screen:{x:number;y:number},camera:THREE.Camera,rect:DOMRect):number|null { this.group.updateWorldMatrix(true,false);camera.updateMatrixWorld(); const picker=new THREE.Raycaster();picker.setFromCamera(new THREE.Vector2((screen.x-rect.left)/rect.width*2-1,1-(screen.y-rect.top)/rect.height*2),camera); const ray=picker.ray.clone().applyMatrix4(this.group.matrixWorld.clone().invert());let top=1; for(const p of this.pieces.values())top=Math.max(top,p.bounds.max.y*1.25+.2); const up=new THREE.Vector3(0,1,0),near=ray.intersectPlane(new THREE.Plane(up,-top),new THREE.Vector3()),far=ray.intersectPlane(new THREE.Plane(up,0),new THREE.Vector3()); const ids=near&&far?this.index.query({x:Math.min(near.x,far.x)-.35,y:Math.min(near.z,far.z)-.35},{x:Math.max(near.x,far.x)+.35,y:Math.max(near.z,far.z)+.35}):new Set(this.pieces.keys()); const a=new THREE.Vector3(),b=new THREE.Vector3(),c=new THREE.Vector3(),hit=new THREE.Vector3(),best=new THREE.Vector3();let distance=Infinity; for(const id of ids){ const p=this.pieces.get(id)!;if(!p.visible)continue; const localRay=ray.clone().applyMatrix4(p.visualMatrix.clone().invert()),positions=p.geometry.attributes.position; for(let i=0;i0; if(jobs.length){this.poseKnife(jobs,plane,0);this.knifeOpacity(.3);} return jobs.length; } clearPreview():void{this.guide.visible=false;if(!this.cut)this.knife.visible=false;this.knifeOpacity(1);} private knifeOpacity(opacity:number):void{ this.knife.traverse(o=>{if(o instanceof THREE.Mesh)for(const m of Array.isArray(o.material)?o.material:[o.material]){m.opacity=opacity;m.transparent=opacity<1;m.depthWrite=opacity===1;}}); } private poseKnife(jobs:Job[],plane:THREE.Plane,elapsed:number):void{ const normal=plane.normal,along=new THREE.Vector3(normal.z,0,-normal.x),average=jobs.reduce((v,j)=>v.add(j.source.position),new THREE.Vector3()).divideScalar(jobs.length); const center=plane.projectPoint(average,new THREE.Vector3()),t=Math.min(1,elapsed/.2),height=Math.max(...jobs.map(j=>j.source.bounds.max.y))+.35; const y=t<.65?height*(1-t/.65):height*((t-.65)/.35); const maxScale=Math.max(...jobs.map(j=>j.source.scale)); this.knife.position.set(center.x,Math.max(.03,y),center.z);this.knife.rotation.set(0,-Math.atan2(along.z,along.x),-.07);this.knife.scale.setScalar(Math.max(.03,Math.min(1.5,maxScale))); } requestCut(a:Vec2,b:Vec2):boolean{ this.clearPreview();if(this.cut||this.disposed)return false; const {plane,jobs}=this.candidates(a,b);if(!jobs.length)return false; this.cut={jobs,plane,a:{...a},b:{...b},elapsed:0,committed:false}; for(const job of jobs){job.pausedMotion=this.motions.get(job.source.id);this.motions.delete(job.source.id);this.softMotions.delete(job.source.id);} return true; } preparedGeometries():THREE.BufferGeometry[]{return this.cut?.jobs.flatMap(j=>j.children.map(p=>p.geometry))??[];} cancel():void{ if(this.cut&&!this.cut.committed)for(const job of this.cut.jobs){ for(const child of job.children)child.geometry.dispose(); if(!this.disposed){ this.startSoft(job.source,-job.source.softPose.squash,job.source.softX,job.source.softZ); if(job.pausedMotion&&!job.pausedMotion.fall.sleeping)this.motions.set(job.source.id,job.pausedMotion); } } this.cut=null;this.knife.visible=false;this.clearPreview(); } update(dt:number):{committed:boolean;revealed:boolean}{ const started=performance.now();this.lastFrameBuilds=0;let committed=false,revealed=false;const dirty=new Set(); const cut=this.cut; if(cut){ const job=cut.jobs.find(j=>j.children.length<2); if(job){ const before=performance.now(); if(!job.split)job.split=splitSolid(job.source.solid,{normal:job.plane.normal,constant:job.plane.constant})??undefined; // 원본 복귀도 인계 종료로 알려 안내·전체 보기 HUD가 진행 중으로 남지 않게 한다. if(!job.split){this.rejected++;this.cancel();return {committed:false,revealed:true};} const child=job.split[job.children.length],source=job.source; const p=this.makePiece(child.solid,new THREE.Vector3(child.center.x,child.center.y,child.center.z).applyMatrix4(source.matrix),source.rotation.clone(),source.scale*child.scale,solidVolume(child.solid)*(source.scale*child.scale)**3); p.matrix.compose(p.position,p.rotation,new THREE.Vector3(p.scale,p.scale,p.scale));job.children.push(p);this.lastFrameBuilds=1;this.prepMaxMs=Math.max(this.prepMaxMs,performance.now()-before); }else{ cut.elapsed+=Math.max(0,dt);this.knife.visible=true; const normal=cut.plane.normal;this.poseKnife(cut.jobs,cut.plane,cut.elapsed); if(!cut.committed&&cut.elapsed>=.13){ for(const job of cut.jobs){ this.pieces.delete(job.source.id);this.index.delete(job.source.id);job.source.geometry.dispose(); job.children.forEach((p,i)=>{ this.pieces.set(p.id,p);const sign=i===0?1:-1,outward={x:normal.x*sign,y:normal.z*sign}; p.position.addScaledVector(normal,sign*Math.min(.018,p.scale*.025)); const fall=new EndlessFall(p.fallShape,p.position,p.rotation,p.scale,outward,this.reducedMotion); p.position.copy(fall.position);p.rotation.copy(fall.rotation);this.sync(p,fall.bounds);this.startSoft(p,ENDLESS_SOFT.compression,outward.x,outward.y); if(!fall.sleeping)this.motions.set(p.id,{piece:p,fall,outward});else this.landings++; }); } cut.committed=true;committed=true;this.cuts++;this.batch.rebuild(this.pieces.values()); } if(cut.elapsed>=.4){this.knife.visible=false;this.cut=null;revealed=true;} } } if(this.motions.size){ for(const [id,m]of this.motions){ const result=m.fall.update(dt);m.piece.position.copy(m.fall.position);m.piece.rotation.copy(m.fall.rotation);this.sync(m.piece,m.fall.bounds);dirty.add(m.piece); if(result.justLanded)this.landings++; if(result.impact>.22)this.startSoft(m.piece,Math.min(ENDLESS_SOFT.compression,Math.max(.045,result.impact*.075)),m.outward.x,m.outward.y); if(m.fall.sleeping)this.motions.delete(id); } } for(const [id,m]of this.softMotions){ m.time+=Math.max(0,dt);if(this.setSoft(m.piece,softSpring(m.time,m.amplitude),m.dirX,m.dirZ))dirty.add(m.piece); if(m.time>=ENDLESS_SOFT.duration)this.softMotions.delete(id); } if(this.cut&&!this.cut.committed)for(const job of this.cut.jobs){ if(this.setSoft(job.source,softPress(this.cut.elapsed/.13,this.reducedMotion),this.cut.plane.normal.x,this.cut.plane.normal.z))dirty.add(job.source); } if(dirty.size){for(const p of dirty)this.batch.write(p);this.batch.flush();} this.updateMaxMs=Math.max(this.updateMaxMs,performance.now()-started);return {committed,revealed}; } refreshVisible(camera:THREE.Camera):void{ camera.updateMatrixWorld();this.group.updateWorldMatrix(true,false); this.frustum=new THREE.Frustum().setFromProjectionMatrix(new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix,camera.matrixWorldInverse));let changed=false; for(const p of this.pieces.values()){ const box=p.bounds.clone().union(p.geometry.boundingBox!.clone().applyMatrix4(p.visualMatrix)).applyMatrix4(this.group.matrixWorld),visible=this.frustum.intersectsBox(box)||this.motions.has(p.id)||this.softMotions.has(p.id); if(p.visible!==visible){p.visible=visible;changed=true;} } if(changed)this.batch.rebuild(this.pieces.values()); } describe(visualBounds=false):{id:number;center:Vec2;bounds:{min:Vec2;max:Vec2};volume:number;height:number;floor:number;centerY:number;tilt:number;worldCenter:THREE.Vector3;worldBounds:THREE.Box3|null}[]{ this.group.updateWorldMatrix(true,false); return [...this.pieces.values()].map(p=>({id:p.id,center:{x:p.position.x,y:p.position.z},bounds:{min:{x:p.bounds.min.x,y:p.bounds.min.z},max:{x:p.bounds.max.x,y:p.bounds.max.z}},volume:p.volume,height:p.bounds.max.y,floor:p.bounds.min.y,centerY:p.position.y,tilt:p.rotation.angleTo(new THREE.Quaternion()),worldCenter:p.fallShape.center.clone().applyMatrix4(p.visualMatrix).applyMatrix4(this.group.matrixWorld),worldBounds:visualBounds?p.geometry.boundingBox!.clone().applyMatrix4(p.visualMatrix).applyMatrix4(this.group.matrixWorld):null})); } stats():EndlessStats{ let volume=0,visiblePieces=0,maxSquash=0,maxShear=0,maxClearance=0;for(const p of this.pieces.values()){volume+=p.volume;if(p.visible)visiblePieces++;maxSquash=Math.max(maxSquash,Math.abs(p.softPose.squash));maxShear=Math.max(maxShear,Math.abs(p.softPose.shear));maxClearance=Math.max(maxClearance,p.bounds.min.y-ENDLESS_FALL.floor);} return {pieces:this.pieces.size,cuts:this.cuts,busy:!!this.cut,prepared:this.cut?.jobs.reduce((n,j)=>n+j.children.length,0)??0,meshBuilds:this.meshBuilds,lastFrameBuilds:this.lastFrameBuilds,inputMeshBuilds:0,volume,rejected:this.rejected,visiblePieces,vertices:this.batch.geometry.drawRange.count,prepMaxMs:this.prepMaxMs,updateMaxMs:this.updateMaxMs,softPieces:this.softMotions.size,maxSquash,maxShear,fallingPieces:this.motions.size,landings:this.landings,maxClearance}; } dispose():void{ if(this.disposed)return;this.disposed=true;this.cancel();this.motions.clear();this.softMotions.clear();for(const p of this.pieces.values())p.geometry.dispose();this.pieces.clear();this.index.clear();this.batch.dispose(); const geometries=new Set(),materials=new Set(),textures=new Set(); this.group.traverse(o=>{if(o instanceof THREE.Mesh&&o!==this.batch.mesh){geometries.add(o.geometry);for(const m of Array.isArray(o.material)?o.material:[o.material])materials.add(m);}}); for(const g of geometries)g.dispose(); for(const m of materials){for(const value of Object.values(m))if(value instanceof THREE.Texture)textures.add(value);m.dispose();} for(const texture of textures)texture.dispose(); this.guide.geometry.dispose();this.guide.material.dispose();this.group.removeFromParent(); } }