@@ -2,13 +2,41 @@ import { test, expect, describe, vi, beforeEach, afterEach } from 'vitest';
22
33// WebGL and OrbitControls cannot run under happy-dom, so only those two are stubbed.
44// Everything else — the scene graph, materials, the ObjectsManager — is real.
5- vi . mock ( 'three/examples/jsm/controls/OrbitControls.js' , ( ) => ( {
6- OrbitControls : class {
7- target = { set : vi . fn ( ) , x : 0 , y : 0 , z : 0 } ;
8- update = vi . fn ( ) ;
9- dispose = vi . fn ( ) ;
5+ vi . mock ( 'three/examples/jsm/controls/OrbitControls.js' , ( ) => {
6+ // the orthographic swap clones and copies the target, so it needs to behave
7+ // like a Vector3 rather than a bag of spies
8+ class Vec3 {
9+ constructor (
10+ public x = 0 ,
11+ public y = 0 ,
12+ public z = 0
13+ ) { }
14+ set ( x : number , y : number , z : number ) {
15+ this . x = x ;
16+ this . y = y ;
17+ this . z = z ;
18+ return this ;
19+ }
20+ copy ( v : { x : number ; y : number ; z : number } ) {
21+ return this . set ( v . x , v . y , v . z ) ;
22+ }
23+ clone ( ) {
24+ return new Vec3 ( this . x , this . y , this . z ) ;
25+ }
26+ toArray ( ) {
27+ return [ this . x , this . y , this . z ] ;
28+ }
1029 }
11- } ) ) ;
30+
31+ return {
32+ OrbitControls : class {
33+ target = new Vec3 ( ) ;
34+ screenSpacePanning = false ;
35+ update = vi . fn ( ) ;
36+ dispose = vi . fn ( ) ;
37+ }
38+ } ;
39+ } ) ;
1240
1341vi . mock ( 'three' , async ( importOriginal ) => {
1442 const actual = await importOriginal < typeof import ( 'three' ) > ( ) ;
@@ -34,7 +62,7 @@ import { SceneManager, type SceneManagerOptions } from '../scene-manager';
3462import { ObjectsManager } from '../objects-manager' ;
3563import { Job } from '../job' ;
3664import { Path , PathType } from '../path' ;
37- import { Color , Group } from 'three' ;
65+ import { Color , Group , OrthographicCamera , PerspectiveCamera } from 'three' ;
3866
3967describe ( 'SceneManager properties' , ( ) => {
4068 let sceneManager : SceneManager ;
@@ -144,6 +172,43 @@ describe('SceneManager properties', () => {
144172 expect ( spy ) . toHaveBeenCalledWith ( expect . anything ( ) , new Color ( '#ff0000' ) , 0 ) ;
145173 } ) ;
146174
175+ test ( 'a tool without a configured color falls back to the last one, warning once' , ( ) => {
176+ // a tool index one past the colors supplied used to read extrusionColor[2]
177+ // as undefined and poison the material color downstream
178+ const job = createJob ( ) ;
179+ const highToolPath = new Path ( PathType . Extrusion , 0.6 , 0.2 , 2 ) ;
180+ highToolPath . addPoint ( 0 , 0 , 0 ) ;
181+ highToolPath . addPoint ( 5 , 0 , 0 ) ;
182+ job . addPath ( highToolPath ) ;
183+
184+ const warn = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => undefined ) ;
185+ const fresh = createSceneManager ( { job, extrusionColor : [ '#00ff00' ] } ) ;
186+ const spy = vi . spyOn ( objectsManager ( fresh ) , 'renderExtrusions' ) ;
187+
188+ expect ( ( ) => fresh . render ( ) ) . not . toThrow ( ) ;
189+
190+ expect ( spy ) . toHaveBeenCalledWith ( expect . anything ( ) , new Color ( '#00ff00' ) , 2 ) ;
191+ expect ( warn ) . toHaveBeenCalledWith ( 'No extrusionColor configured for tool index 2, falling back to another color' ) ;
192+
193+ fresh . render ( ) ;
194+ expect ( warn ) . toHaveBeenCalledTimes ( 1 ) ;
195+
196+ fresh . dispose ( ) ;
197+ } ) ;
198+
199+ test ( 'falls back to the default extrusion color when the color array is empty' , ( ) => {
200+ const warn = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => undefined ) ;
201+ const fresh = createSceneManager ( { job : createJob ( ) , extrusionColor : [ ] } ) ;
202+ const spy = vi . spyOn ( objectsManager ( fresh ) , 'renderExtrusions' ) ;
203+
204+ expect ( ( ) => fresh . render ( ) ) . not . toThrow ( ) ;
205+
206+ expect ( spy ) . toHaveBeenCalledWith ( expect . anything ( ) , SceneManager . defaultExtrusionColor , 0 ) ;
207+
208+ fresh . dispose ( ) ;
209+ warn . mockRestore ( ) ;
210+ } ) ;
211+
147212 test ( 'extrusionColor accepts an array, one entry per tool' , ( ) => {
148213 const spy = vi . spyOn ( objectsManager ( sceneManager ) , 'setExtrusionColor' ) ;
149214
@@ -420,6 +485,103 @@ describe('SceneManager properties', () => {
420485 } ) ;
421486 } ) ;
422487
488+ describe ( 'orthographic camera' , ( ) => {
489+ test ( 'starts perspective' , ( ) => {
490+ expect ( sceneManager . orthographic ) . toBe ( false ) ;
491+ expect ( sceneManager . camera ) . toBeInstanceOf ( PerspectiveCamera ) ;
492+ } ) ;
493+
494+ test ( 'swaps the camera when enabled' , ( ) => {
495+ sceneManager . orthographic = true ;
496+
497+ expect ( sceneManager . orthographic ) . toBe ( true ) ;
498+ expect ( sceneManager . camera ) . toBeInstanceOf ( OrthographicCamera ) ;
499+ } ) ;
500+
501+ test ( 'swaps back when disabled' , ( ) => {
502+ sceneManager . orthographic = true ;
503+ sceneManager . orthographic = false ;
504+
505+ expect ( sceneManager . camera ) . toBeInstanceOf ( PerspectiveCamera ) ;
506+ } ) ;
507+
508+ test ( 'carries the camera position and target across the swap' , ( ) => {
509+ sceneManager . camera . position . set ( 11 , 22 , 33 ) ;
510+ sceneManager . controls . target . set ( 1 , 2 , 3 ) ;
511+
512+ sceneManager . orthographic = true ;
513+
514+ expect ( sceneManager . camera . position . toArray ( ) ) . toEqual ( [ 11 , 22 , 33 ] ) ;
515+ expect ( sceneManager . controls . target . toArray ( ) ) . toEqual ( [ 1 , 2 , 3 ] ) ;
516+ } ) ;
517+
518+ test ( 'enables screen space panning in orthographic mode' , ( ) => {
519+ sceneManager . orthographic = true ;
520+
521+ expect ( sceneManager . controls . screenSpacePanning ) . toBe ( true ) ;
522+ } ) ;
523+
524+ test ( 'ignores a repeated value' , ( ) => {
525+ sceneManager . orthographic = true ;
526+ const camera = sceneManager . camera ;
527+
528+ sceneManager . orthographic = true ;
529+
530+ expect ( sceneManager . camera ) . toBe ( camera ) ;
531+ } ) ;
532+
533+ test ( 'sizes the frustum from the job bounds' , ( ) => {
534+ sceneManager . orthographic = true ;
535+
536+ // the test job spans 10mm, so the frustum is padded around that
537+ const camera = sceneManager . camera as OrthographicCamera ;
538+ expect ( camera . top ) . toBeGreaterThan ( 0 ) ;
539+ expect ( camera . left ) . toBeLessThan ( 0 ) ;
540+ } ) ;
541+
542+ test ( 'falls back to the build volume when the job has no bounds' , ( ) => {
543+ const job = new Job ( ) ;
544+ appendPath ( job , PathType . Extrusion , [
545+ [ 0 , 0 , 0 ] ,
546+ [ 10 , 0 , 0 ]
547+ ] ) ;
548+ const unbounded = createSceneManager ( { job } ) ;
549+
550+ unbounded . orthographic = true ;
551+
552+ const camera = unbounded . camera as OrthographicCamera ;
553+ // build volume is 200mm, much larger than the job
554+ expect ( camera . top ) . toBeGreaterThan ( 50 ) ;
555+ unbounded . dispose ( ) ;
556+ } ) ;
557+
558+ test ( 'falls back to a default size with neither bounds nor build volume' , ( ) => {
559+ const job = new Job ( ) ;
560+ appendPath ( job , PathType . Extrusion , [
561+ [ 0 , 0 , 0 ] ,
562+ [ 10 , 0 , 0 ]
563+ ] ) ;
564+ const bare = createSceneManager ( { job } ) ;
565+ bare . buildVolume = undefined ;
566+
567+ bare . orthographic = true ;
568+
569+ expect ( bare . camera ) . toBeInstanceOf ( OrthographicCamera ) ;
570+ bare . dispose ( ) ;
571+ } ) ;
572+
573+ test ( 'resize keeps the orthographic frustum square to the canvas' , ( ) => {
574+ sceneManager . orthographic = true ;
575+ const camera = sceneManager . camera as OrthographicCamera ;
576+ const widthBefore = camera . right - camera . left ;
577+
578+ sceneManager . resize ( ) ;
579+
580+ expect ( camera . right - camera . left ) . toBeCloseTo ( widthBefore , 5 ) ;
581+ expect ( camera . right ) . toBeGreaterThan ( camera . top ) ;
582+ } ) ;
583+ } ) ;
584+
423585 describe ( 'progressive rendering' , ( ) => {
424586 test ( 'renders every path across frames' , async ( ) => {
425587 sceneManager . clear ( ) ;
@@ -542,7 +704,6 @@ describe('SceneManager properties', () => {
542704 travelColor : '#404040' ,
543705 topLayerColor : '#505050' ,
544706 lastSegmentColor : '#606060' ,
545- toolColors : { 0 : '#707070' } ,
546707 disableGradient : true ,
547708 renderTubes : true ,
548709 renderTravel : true ,
0 commit comments