diff --git a/apps/control-station/public/rover-scene/dcd006-v020.glb b/apps/control-station/public/rover-scene/dcd006-v020.glb index 068a892..305b0bb 100644 --- a/apps/control-station/public/rover-scene/dcd006-v020.glb +++ b/apps/control-station/public/rover-scene/dcd006-v020.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15f17f5b5b014e0f65273aa1565b7fb8e46381ca389a3c3d3ca89a32806bfb79 -size 54960576 +oid sha256:3b9aa6f828c7a4ca1c5581b84299164fcddc9ecb37f56ec1f5341ce6df0f0cfa +size 72891272 diff --git a/apps/control-station/public/rover-scene/dcd006-v020.json b/apps/control-station/public/rover-scene/dcd006-v020.json index f14fdb4..10d09ba 100644 --- a/apps/control-station/public/rover-scene/dcd006-v020.json +++ b/apps/control-station/public/rover-scene/dcd006-v020.json @@ -2,8 +2,9 @@ "source": "DCD-006_rover_v020.blend", "source_sha256": "c49a0470974bbe65b4e65b4b5c06cb887730715a1ddfa231b86426fed6de8f79", "model": "DCD-006 v020 complete rover", + "revision": "materials-v2", "source_object_count": 1426, - "render_mesh_count": 1, + "render_mesh_count": 9, "original_bounds": [ [ -0.527999997138977, @@ -26,14 +27,1419 @@ -0.0, -0.0 ], - "glb_bytes": 54960576, - "glb_sha256": "15f17f5b5b014e0f65273aa1565b7fb8e46381ca389a3c3d3ca89a32806bfb79", - "material_source_sha256": "ac50e29fe9fe7ab4012628c52fce30e262e9e0bba4a4d49abe59a90c84391b2e", - "material_rules": { - "metal": 1, - "rubber": 2, - "steel": 6, - "paint": 3 - }, - "source_saved": false + "glb_bytes": 72891272, + "glb_sha256": "3b9aa6f828c7a4ca1c5581b84299164fcddc9ecb37f56ec1f5341ce6df0f0cfa", + "source_saved": false, + "uv": "cube projection, 0.2 m", + "materials": { + "body": { + "name": "Корпус и подвеска", + "baseColorLinear": [ + 0.025, + 0.028, + 0.031 + ], + "metalness": 0.12, + "roughness": 0.53, + "sourceObjects": [ + "BASE | welded load sheet", + "BODY PATCH | observed welded rectangle", + "BODY SHEET | single mounting leaf fitted to tube", + "CHASSIS TIE 1 | cross-tube", + "CHASSIS TIE 1 | plate sleeve -1", + "CHASSIS TIE 1 | plate sleeve 1", + "CHASSIS TIE 2 | cross-tube", + "CHASSIS TIE 2 | plate sleeve -1", + "CHASSIS TIE 2 | plate sleeve 1", + "CHASSIS TIE 3 | cross-tube", + "CHASSIS TIE 3 | plate sleeve -1", + "CHASSIS TIE 3 | plate sleeve 1", + "COMMS | CONCEPT M22 blanking plug", + "COMMS | CONCEPT M22 blanking plug.001", + "COMMS | CONCEPT M22 blanking plug.002", + "COMMS | CONCEPT M22 blanking plug.003", + "COMMS | ROUTER body hinge lug", + "COMMS | ROUTER body hinge lug.001", + "COMMS | ROUTER end casting rib", + "COMMS | ROUTER end casting rib.001", + "COMMS | ROUTER end casting rib.002", + "COMMS | ROUTER end casting rib.003", + "COMMS | ROUTER end casting rib.004", + "COMMS | ROUTER end casting rib.005", + "COMMS | ROUTER end casting rib.006", + "COMMS | ROUTER end casting rib.007", + "COMMS | ROUTER end casting rib.008", + "COMMS | ROUTER end casting rib.009", + "COMMS | ROUTER end casting rib.010", + "COMMS | ROUTER end casting rib.011", + "COMMS | ROUTER end casting rib.012", + "COMMS | ROUTER end casting rib.013", + "COMMS | ROUTER end casting rib.014", + "COMMS | ROUTER end casting rib.015", + "COMMS | ROUTER end casting rib.016", + "COMMS | ROUTER end casting rib.017", + "COMMS | ROUTER end casting rib.018", + "COMMS | ROUTER end casting rib.019", + "COMMS | ROUTER end casting rib.020", + "COMMS | ROUTER end casting rib.021", + "COMMS | ROUTER end casting rib.022", + "COMMS | ROUTER end casting rib.023", + "COMMS | ROUTER end casting rib.024", + "COMMS | ROUTER end casting rib.025", + "COMMS | ROUTER hinge middle barrel", + "COMMS | ROUTER hinge middle barrel.001", + "COMMS | ROUTER hinge outer barrel", + "COMMS | ROUTER hinge outer barrel.001", + "COMMS | ROUTER hinge outer barrel.002", + "COMMS | ROUTER hinge outer barrel.003", + "COMMS | ROUTER hollow cast body", + "COMMS | ROUTER lid cooling fin 1", + "COMMS | ROUTER lid cooling fin 10", + "COMMS | ROUTER lid cooling fin 11", + "COMMS | ROUTER lid cooling fin 12", + "COMMS | ROUTER lid cooling fin 13", + "COMMS | ROUTER lid cooling fin 2", + "COMMS | ROUTER lid cooling fin 3", + "COMMS | ROUTER lid cooling fin 4", + "COMMS | ROUTER lid cooling fin 5", + "COMMS | ROUTER lid cooling fin 6", + "COMMS | ROUTER lid cooling fin 7", + "COMMS | ROUTER lid cooling fin 8", + "COMMS | ROUTER lid cooling fin 9", + "COMMS | ROUTER lid hinge leaf", + "COMMS | ROUTER lid hinge leaf.001", + "COMMS | ROUTER lid shell", + "COMMS | ROUTER mounting foot", + "COMMS | ROUTER mounting foot.001", + "COMMS | ROUTER mounting foot.002", + "COMMS | ROUTER mounting foot.003", + "COMMS | ROUTER raised blank badge", + "COMMS | SG1 foot flange", + "COMMS | SG1 hollow threaded stand", + "COMMS | SG1 upper coupling sleeve", + "COMMS | SG2 foot flange", + "COMMS | SG2 hollow threaded stand", + "COMMS | SG2 upper coupling sleeve", + "DRIVE | estimated 3.5 mm adapter plate", + "DRIVE | estimated 3.5 mm adapter plate.001", + "FRAME v007 | single bent tube with two top closures", + "L DRIVE | LAMINATED ring -1 layer 1", + "L DRIVE | LAMINATED ring -1 layer 2", + "L DRIVE | LAMINATED ring -1 layer 3", + "L DRIVE | LAMINATED ring -1 layer 4", + "L DRIVE | LAMINATED ring -1 layer 5", + "L DRIVE | LAMINATED ring 1 layer 1", + "L DRIVE | LAMINATED ring 1 layer 2", + "L DRIVE | LAMINATED ring 1 layer 3", + "L DRIVE | LAMINATED ring 1 layer 4", + "L DRIVE | LAMINATED ring 1 layer 5", + "L DRIVE | angle flange edge weld", + "L DRIVE | angle flange edge weld.001", + "L DRIVE | angle flange edge weld.002", + "L DRIVE | angle flange edge weld.003", + "L DRIVE | angle lower inclined flange 50x80x3", + "L DRIVE | angle rear vertex weld", + "L DRIVE | angle upper inclined flange 55x80x3", + "L DRIVE | radiused motor mounting plate", + "L REAR | flat rocker cheek -1", + "L REAR | flat rocker cheek 1", + "L REAR | flat web edge weld", + "L REAR | flat web edge weld.001", + "L REAR | flat welded roller-side web", + "L REAR | welded threaded pivot sleeve", + "L SHOCK | bicycle spring 750LB_IN", + "L SHOCK | fixed mount ear", + "L SHOCK | fixed mount ear.001", + "L SPRING_1 | COMPRESSION COIL", + "L SPRING_1 | fixed floor locating ear", + "L SPRING_1 | fixed floor locating ear.001", + "L SPRING_1 | fixed horizontal lower plate", + "L SPRING_1 | separate tabbed top plate", + "L SPRING_1 | separate tabbed top plate locating ear", + "L SPRING_1 | separate tabbed top plate locating ear.001", + "L SPRING_2 | COMPRESSION COIL", + "L SPRING_2 | moving lower reaction plate", + "L SPRING_2 | reaction plate edge weld", + "L SPRING_2 | reaction plate edge weld.001", + "L SPRING_2 | separate tabbed top plate", + "L SPRING_2 | separate tabbed top plate locating ear", + "L SPRING_2 | separate tabbed top plate locating ear.001", + "L SPRING_2 | stud root weld", + "L SPRING_3 | COMPRESSION COIL", + "L SPRING_3 | moving lower reaction plate", + "L SPRING_3 | radiused tube saddle", + "L SPRING_3 | reaction plate edge weld", + "L SPRING_3 | reaction plate edge weld.001", + "L SPRING_3 | separate tabbed top plate", + "L SPRING_3 | separate tabbed top plate locating ear", + "L SPRING_3 | separate tabbed top plate locating ear.001", + "L SPRING_3 | stud root weld", + "L front_lower | LAMINATED ring -1 layer 1", + "L front_lower | LAMINATED ring -1 layer 2", + "L front_lower | LAMINATED ring -1 layer 3", + "L front_lower | LAMINATED ring -1 layer 4", + "L front_lower | LAMINATED ring 1 layer 1", + "L front_lower | LAMINATED ring 1 layer 2", + "L front_lower | LAMINATED ring 1 layer 3", + "L front_lower | LAMINATED ring 1 layer 4", + "L front_lower | LOWER flat cheek -1", + "L front_lower | LOWER flat cheek 1", + "L front_lower | LOWER flat welded web", + "L front_lower | LOWER web edge weld", + "L front_lower | LOWER web edge weld.001", + "L front_lower | moving pivot sleeve", + "L front_lower | roller axle sleeve", + "L middle_lower | LAMINATED ring -1 layer 1", + "L middle_lower | LAMINATED ring -1 layer 2", + "L middle_lower | LAMINATED ring -1 layer 3", + "L middle_lower | LAMINATED ring -1 layer 4", + "L middle_lower | LAMINATED ring 1 layer 1", + "L middle_lower | LAMINATED ring 1 layer 2", + "L middle_lower | LAMINATED ring 1 layer 3", + "L middle_lower | LAMINATED ring 1 layer 4", + "L middle_lower | LOWER flat cheek -1", + "L middle_lower | LOWER flat cheek 1", + "L middle_lower | LOWER flat welded web", + "L middle_lower | LOWER web edge weld", + "L middle_lower | LOWER web edge weld.001", + "L middle_lower | moving pivot sleeve", + "L middle_lower | roller axle sleeve", + "L rear_lower | LAMINATED ring -1 layer 1", + "L rear_lower | LAMINATED ring -1 layer 2", + "L rear_lower | LAMINATED ring -1 layer 3", + "L rear_lower | LAMINATED ring -1 layer 4", + "L rear_lower | LAMINATED ring 1 layer 1", + "L rear_lower | LAMINATED ring 1 layer 2", + "L rear_lower | LAMINATED ring 1 layer 3", + "L rear_lower | LAMINATED ring 1 layer 4", + "L rear_lower | roller axle sleeve", + "L rear_upper | LAMINATED ring -1 layer 1", + "L rear_upper | LAMINATED ring -1 layer 2", + "L rear_upper | LAMINATED ring -1 layer 3", + "L rear_upper | LAMINATED ring -1 layer 4", + "L rear_upper | LAMINATED ring 1 layer 1", + "L rear_upper | LAMINATED ring 1 layer 2", + "L rear_upper | LAMINATED ring 1 layer 3", + "L rear_upper | LAMINATED ring 1 layer 4", + "L rear_upper | roller axle sleeve", + "L | FIXED cheek -1 | 3mm tapered package", + "L | FIXED cheek 1 | 3mm tapered package", + "L | UPPER rocker cheek -1", + "L | UPPER rocker cheek 1", + "L | UPPER straight transverse web", + "L | UPPER web tack weld", + "L | UPPER web tack weld.001", + "L | UPPER web tack weld.002", + "L | UPPER web tack weld.003", + "L | UPPER web tack weld.004", + "L | UPPER web tack weld.005", + "L | fixed top bridge | 150mm", + "L | top bridge fitted edge weld", + "L | top bridge fitted edge weld.001", + "L | top bridge weld-in tab", + "L | top bridge weld-in tab.001", + "L | top bridge weld-in tab.002", + "L | top bridge weld-in tab.003", + "R DRIVE | LAMINATED ring -1 layer 1", + "R DRIVE | LAMINATED ring -1 layer 2", + "R DRIVE | LAMINATED ring -1 layer 3", + "R DRIVE | LAMINATED ring -1 layer 4", + "R DRIVE | LAMINATED ring -1 layer 5", + "R DRIVE | LAMINATED ring 1 layer 1", + "R DRIVE | LAMINATED ring 1 layer 2", + "R DRIVE | LAMINATED ring 1 layer 3", + "R DRIVE | LAMINATED ring 1 layer 4", + "R DRIVE | LAMINATED ring 1 layer 5", + "R DRIVE | angle flange edge weld", + "R DRIVE | angle flange edge weld.001", + "R DRIVE | angle flange edge weld.002", + "R DRIVE | angle flange edge weld.003", + "R DRIVE | angle lower inclined flange 50x80x3", + "R DRIVE | angle rear vertex weld", + "R DRIVE | angle upper inclined flange 55x80x3", + "R DRIVE | radiused motor mounting plate", + "R REAR | flat rocker cheek -1", + "R REAR | flat rocker cheek 1", + "R REAR | flat web edge weld", + "R REAR | flat web edge weld.001", + "R REAR | flat welded roller-side web", + "R REAR | welded threaded pivot sleeve", + "R SHOCK | bicycle spring 750LB_IN", + "R SHOCK | fixed mount ear", + "R SHOCK | fixed mount ear.001", + "R SPRING_1 | COMPRESSION COIL", + "R SPRING_1 | fixed floor locating ear", + "R SPRING_1 | fixed floor locating ear.001", + "R SPRING_1 | fixed horizontal lower plate", + "R SPRING_1 | separate tabbed top plate", + "R SPRING_1 | separate tabbed top plate locating ear", + "R SPRING_1 | separate tabbed top plate locating ear.001", + "R SPRING_2 | COMPRESSION COIL", + "R SPRING_2 | moving lower reaction plate", + "R SPRING_2 | reaction plate edge weld", + "R SPRING_2 | reaction plate edge weld.001", + "R SPRING_2 | separate tabbed top plate", + "R SPRING_2 | separate tabbed top plate locating ear", + "R SPRING_2 | separate tabbed top plate locating ear.001", + "R SPRING_2 | stud root weld", + "R SPRING_3 | COMPRESSION COIL", + "R SPRING_3 | moving lower reaction plate", + "R SPRING_3 | radiused tube saddle", + "R SPRING_3 | reaction plate edge weld", + "R SPRING_3 | reaction plate edge weld.001", + "R SPRING_3 | separate tabbed top plate", + "R SPRING_3 | separate tabbed top plate locating ear", + "R SPRING_3 | separate tabbed top plate locating ear.001", + "R SPRING_3 | stud root weld", + "R front_lower | LAMINATED ring -1 layer 1", + "R front_lower | LAMINATED ring -1 layer 2", + "R front_lower | LAMINATED ring -1 layer 3", + "R front_lower | LAMINATED ring -1 layer 4", + "R front_lower | LAMINATED ring 1 layer 1", + "R front_lower | LAMINATED ring 1 layer 2", + "R front_lower | LAMINATED ring 1 layer 3", + "R front_lower | LAMINATED ring 1 layer 4", + "R front_lower | LOWER flat cheek -1", + "R front_lower | LOWER flat cheek 1", + "R front_lower | LOWER flat welded web", + "R front_lower | LOWER web edge weld", + "R front_lower | LOWER web edge weld.001", + "R front_lower | moving pivot sleeve", + "R front_lower | roller axle sleeve", + "R middle_lower | LAMINATED ring -1 layer 1", + "R middle_lower | LAMINATED ring -1 layer 2", + "R middle_lower | LAMINATED ring -1 layer 3", + "R middle_lower | LAMINATED ring -1 layer 4", + "R middle_lower | LAMINATED ring 1 layer 1", + "R middle_lower | LAMINATED ring 1 layer 2", + "R middle_lower | LAMINATED ring 1 layer 3", + "R middle_lower | LAMINATED ring 1 layer 4", + "R middle_lower | LOWER flat cheek -1", + "R middle_lower | LOWER flat cheek 1", + "R middle_lower | LOWER flat welded web", + "R middle_lower | LOWER web edge weld", + "R middle_lower | LOWER web edge weld.001", + "R middle_lower | moving pivot sleeve", + "R middle_lower | roller axle sleeve", + "R rear_lower | LAMINATED ring -1 layer 1", + "R rear_lower | LAMINATED ring -1 layer 2", + "R rear_lower | LAMINATED ring -1 layer 3", + "R rear_lower | LAMINATED ring -1 layer 4", + "R rear_lower | LAMINATED ring 1 layer 1", + "R rear_lower | LAMINATED ring 1 layer 2", + "R rear_lower | LAMINATED ring 1 layer 3", + "R rear_lower | LAMINATED ring 1 layer 4", + "R rear_lower | roller axle sleeve", + "R rear_upper | LAMINATED ring -1 layer 1", + "R rear_upper | LAMINATED ring -1 layer 2", + "R rear_upper | LAMINATED ring -1 layer 3", + "R rear_upper | LAMINATED ring -1 layer 4", + "R rear_upper | LAMINATED ring 1 layer 1", + "R rear_upper | LAMINATED ring 1 layer 2", + "R rear_upper | LAMINATED ring 1 layer 3", + "R rear_upper | LAMINATED ring 1 layer 4", + "R rear_upper | roller axle sleeve", + "R | FIXED cheek -1 | 3mm tapered package", + "R | FIXED cheek 1 | 3mm tapered package", + "R | UPPER rocker cheek -1", + "R | UPPER rocker cheek 1", + "R | UPPER straight transverse web", + "R | UPPER web tack weld", + "R | UPPER web tack weld.001", + "R | UPPER web tack weld.002", + "R | UPPER web tack weld.003", + "R | UPPER web tack weld.004", + "R | UPPER web tack weld.005", + "R | fixed top bridge | 150mm", + "R | top bridge fitted edge weld", + "R | top bridge fitted edge weld.001", + "R | top bridge weld-in tab", + "R | top bridge weld-in tab.001", + "R | top bridge weld-in tab.002", + "R | top bridge weld-in tab.003", + "SUBFRAME | end RHS40x20", + "SUBFRAME | end RHS40x20.001", + "SUBFRAME | longitudinal RHS40x20 -1", + "SUBFRAME | longitudinal RHS40x20 1", + "VAN004 | 300mm estimated double channel extrusion", + "VAN004 | CAMERA clamp boss", + "VAN004 | CAMERA front carriage flange", + "VAN004 | CAMERA grip rib", + "VAN004 | CAMERA grip rib.001", + "VAN004 | CAMERA grip rib.002", + "VAN004 | CAMERA grip rib.003", + "VAN004 | CAMERA grip rib.004", + "VAN004 | CAMERA grip rib.005", + "VAN004 | CAMERA grip rib.006", + "VAN004 | CAMERA grip rib.007", + "VAN004 | CAMERA grip rib.008", + "VAN004 | CAMERA grip rib.009", + "VAN004 | CAMERA grip rib.010", + "VAN004 | CAMERA grip rib.011", + "VAN004 | CAMERA grip rib.012", + "VAN004 | CAMERA grip rib.013", + "VAN004 | CAMERA grip rib.014", + "VAN004 | CAMERA grip rib.015", + "VAN004 | CAMERA grip rib.016", + "VAN004 | CAMERA grip rib.017", + "VAN004 | CAMERA paired socket jaws", + "VAN004 | CAMERA pedestal neck", + "VAN004 | DEVICE clamp boss", + "VAN004 | DEVICE front carriage flange", + "VAN004 | DEVICE grip rib", + "VAN004 | DEVICE grip rib.001", + "VAN004 | DEVICE grip rib.002", + "VAN004 | DEVICE grip rib.003", + "VAN004 | DEVICE grip rib.004", + "VAN004 | DEVICE grip rib.005", + "VAN004 | DEVICE grip rib.006", + "VAN004 | DEVICE grip rib.007", + "VAN004 | DEVICE grip rib.008", + "VAN004 | DEVICE grip rib.009", + "VAN004 | DEVICE grip rib.010", + "VAN004 | DEVICE grip rib.011", + "VAN004 | DEVICE grip rib.012", + "VAN004 | DEVICE grip rib.013", + "VAN004 | DEVICE grip rib.014", + "VAN004 | DEVICE grip rib.015", + "VAN004 | DEVICE grip rib.016", + "VAN004 | DEVICE grip rib.017", + "VAN004 | DEVICE paired socket jaws", + "VAN004 | DEVICE pedestal neck", + "VAN004 | camera end neck", + "VAN004 | camera flange", + "VAN004 | centre gusset web", + "VAN004 | centre gusset web.001", + "VAN004 | cradle concentric rear reinforcement 0", + "VAN004 | cradle concentric rear reinforcement 1", + "VAN004 | cradle concentric rear reinforcement 2", + "VAN004 | ear seating plate -1", + "VAN004 | ear seating plate 1", + "VAN004 | extrusion clamp sleeve", + "VAN004 | extrusion clamp sleeve.001", + "VAN004 | extrusion edge bead", + "VAN004 | extrusion edge bead.001", + "VAN004 | extrusion edge bead.002", + "VAN004 | extrusion edge bead.003", + "VAN004 | holder ball neck", + "VAN004 | hollow triangular end cheek", + "VAN004 | hollow triangular end cheek.001", + "VAN004 | hollow triangular end cheek.002", + "VAN004 | hollow triangular end cheek.003", + "VAN004 | inner lower landing", + "VAN004 | inner lower landing.001", + "VAN004 | landscape width slide bridge", + "VAN004 | lid stand off.002", + "VAN004 | lid stand off.003", + "VAN004 | outer underside fastener threaded shank", + "VAN004 | outer underside fastener threaded shank.001", + "VAN004 | outer underside fastener threaded shank.002", + "VAN004 | outer underside fastener threaded shank.003", + "VAN004 | shallow open mounting tray", + "VAN004 | shallow open mounting tray.001", + "VAN004 | tray corner stud", + "VAN004 | tray corner stud.001", + "VAN004 | tray corner stud.002", + "VAN004 | tray corner stud.003", + "VAN004 | tray corner stud.004", + "VAN004 | tray corner stud.005", + "VAN004 | tray corner stud.006", + "VAN004 | tray corner stud.007", + "VAN004 | tray outer rim", + "VAN004 | tray outer rim.001", + "VAN004 | tray side rim", + "VAN004 | tray side rim.001", + "VAN004 | tray side rim.002", + "VAN004 | tray side rim.003" + ] + }, + "rubber": { + "name": "Резина гусениц и катков", + "baseColorLinear": [ + 0.014, + 0.017, + 0.019 + ], + "metalness": 0, + "roughness": 0.72, + "sourceObjects": [ + "CASE 02 | continuous perimeter seal", + "CASE | rubber support foot", + "CASE | rubber support foot.001", + "CASE | rubber support foot.002", + "CASE | rubber support foot.003", + "COMMS | COAX 01 SG1 RG174 continuous", + "COMMS | COAX 02 SG1 RG174 continuous", + "COMMS | COAX 03 SG1 RG174 continuous", + "COMMS | COAX 04 SG1 RG174 continuous", + "COMMS | COAX 05 SG2 RG174 continuous", + "COMMS | COAX 06 SG2 RG174 continuous", + "COMMS | COAX 07 SG2 RG174 continuous", + "COMMS | COAX 08 SG2 RG174 continuous", + "COMMS | COAX 1 SMA strain relief", + "COMMS | COAX 2 SMA strain relief", + "COMMS | COAX 3 SMA strain relief", + "COMMS | COAX 4 SMA strain relief", + "COMMS | COAX 5 SMA strain relief", + "COMMS | COAX 6 SMA strain relief", + "COMMS | COAX 7 SMA strain relief", + "COMMS | COAX 8 SMA strain relief", + "COMMS | CONCEPT unused port plug O-ring", + "COMMS | CONCEPT unused port plug O-ring.001", + "COMMS | CONCEPT unused port plug O-ring.002", + "COMMS | CONCEPT unused port plug O-ring.003", + "COMMS | ROUTER contact pad", + "COMMS | ROUTER contact pad.001", + "COMMS | ROUTER contact pad.002", + "COMMS | ROUTER contact pad.003", + "COMMS | ROUTER perimeter gasket", + "COMMS | SG1 antenna seating gasket", + "COMMS | SG1 base seal", + "COMMS | SG1 foot gasket", + "COMMS | SG2 antenna seating gasket", + "COMMS | SG2 base seal", + "COMMS | SG2 foot gasket", + "L MURAVEY v014 | 44 rows • four small V lugs and two centre d", + "L MURAVEY v014 | continuous perforated rubber carcass", + "L MURAVEY v014 | fine transverse mould ribs and edge rails", + "L MURAVEY v014 | inner guide bosses • photo estimate", + "L front_lower | ROLLER125 lane -1", + "L front_lower | ROLLER125 lane 1", + "L front_lower | flush centre cover -1", + "L front_lower | flush centre cover 1", + "L middle_lower | ROLLER125 lane -1", + "L middle_lower | ROLLER125 lane 1", + "L middle_lower | flush centre cover -1", + "L middle_lower | flush centre cover 1", + "L rear_lower | ROLLER125 lane -1", + "L rear_lower | ROLLER125 lane 1", + "L rear_lower | flush centre cover -1", + "L rear_lower | flush centre cover 1", + "L rear_upper | ROLLER125 lane -1", + "L rear_upper | ROLLER125 lane 1", + "L rear_upper | flush centre cover -1", + "L rear_upper | flush centre cover 1", + "R MURAVEY v014 | 44 rows • four small V lugs and two centre d", + "R MURAVEY v014 | continuous perforated rubber carcass", + "R MURAVEY v014 | fine transverse mould ribs and edge rails", + "R MURAVEY v014 | inner guide bosses • photo estimate", + "R front_lower | ROLLER125 lane -1", + "R front_lower | ROLLER125 lane 1", + "R front_lower | flush centre cover -1", + "R front_lower | flush centre cover 1", + "R middle_lower | ROLLER125 lane -1", + "R middle_lower | ROLLER125 lane 1", + "R middle_lower | flush centre cover -1", + "R middle_lower | flush centre cover 1", + "R rear_lower | ROLLER125 lane -1", + "R rear_lower | ROLLER125 lane 1", + "R rear_lower | flush centre cover -1", + "R rear_lower | flush centre cover 1", + "R rear_upper | ROLLER125 lane -1", + "R rear_upper | ROLLER125 lane 1", + "R rear_upper | flush centre cover -1", + "R rear_upper | flush centre cover 1", + "VAN004 | CAMERA base ball", + "VAN004 | CAMERA end ball", + "VAN004 | DEVICE base ball", + "VAN004 | DEVICE end ball", + "VAN004 | jaw inward rubber strip", + "VAN004 | jaw inward rubber strip.001", + "VAN004 | landscape rubber pad", + "VAN004 | lid contact pad", + "VAN004 | lid contact pad.001" + ] + }, + "track-steel": { + "name": "Нержавеющие проставки", + "baseColorLinear": [ + 0.55, + 0.58, + 0.61 + ], + "metalness": 1, + "roughness": 0.17, + "sourceObjects": [ + "L MURAVEY v014 | 44 pressed inter-window clips", + "R MURAVEY v014 | 44 pressed inter-window clips" + ] + }, + "box": { + "name": "Бортовой ящик", + "baseColorLinear": [ + 0.034, + 0.038, + 0.042 + ], + "metalness": 0.08, + "roughness": 0.4, + "sourceObjects": [ + "CASE 01 | photo-shaped lower shell", + "CASE 03 | chamfered lid, traced silhouette", + "CASE | embossed V2", + "CASE | front lower patterned pad", + "CASE | front lower patterned pad.001", + "CASE | latch 0 cradle", + "CASE | latch 0 folded edge", + "CASE | latch 0 key slot", + "CASE | latch 0 shaped lever", + "CASE | latch 1 cradle", + "CASE | latch 1 folded edge", + "CASE | latch 1 key slot", + "CASE | latch 1 shaped lever", + "CASE | opposite hinge leaf", + "CASE | opposite hinge leaf.001", + "CASE | pad fine striation", + "CASE | pad fine striation.001", + "CASE | pad fine striation.002", + "CASE | pad fine striation.003", + "CASE | pad fine striation.004", + "CASE | pad fine striation.005", + "CASE | pad fine striation.006", + "CASE | pad fine striation.007", + "CASE | pad fine striation.008", + "CASE | pad fine striation.009", + "CASE | pad fine striation.010", + "CASE | pad fine striation.011", + "CASE | pad fine striation.012", + "CASE | pad fine striation.013", + "CASE | pad fine striation.014", + "CASE | pad fine striation.015", + "CASE | pad fine striation.016", + "CASE | pad fine striation.017", + "CASE | pad fine striation.018", + "CASE | pad fine striation.019", + "CASE | pad fine striation.020", + "CASE | pad fine striation.021", + "CASE | pad fine striation.022", + "CASE | pad fine striation.023", + "CASE | pad fine striation.024", + "CASE | pad fine striation.025", + "CASE | pad fine striation.026", + "CASE | pad fine striation.027", + "CASE | pad fine striation.028", + "CASE | pad fine striation.029", + "CASE | pad fine striation.030", + "CASE | pad fine striation.031", + "CASE | pad fine striation.032", + "CASE | pad fine striation.033", + "CASE | pad fine striation.034", + "CASE | pad fine striation.035", + "CASE | relief centre field", + "CASE | sculpted front relief field", + "MOLLE | WINBO maker badge", + "MOLLE | black screw", + "MOLLE | black screw.001", + "MOLLE | black screw.002", + "MOLLE | black screw.003" + ] + }, + "molle": { + "name": "MOLLE панель", + "baseColorLinear": [ + 0.085, + 0.094, + 0.105 + ], + "metalness": 0.3, + "roughness": 0.44, + "sourceObjects": [ + "MOLLE 307 x 435 | traced perforated plate", + "MOLLE | diagonal bridge over logo", + "MOLLE | pressed perimeter detail 0", + "MOLLE | pressed perimeter detail 1", + "MOLLE | pressed perimeter detail 2" + ] + }, + "motor": { + "name": "Алюминий моторов", + "baseColorLinear": [ + 0.3, + 0.32, + 0.33 + ], + "metalness": 0.68, + "roughness": 0.38, + "sourceObjects": [ + "HQF_L_case_bolt_boss_00", + "HQF_L_case_bolt_boss_01", + "HQF_L_case_bolt_boss_02", + "HQF_L_case_bolt_boss_03", + "HQF_L_case_bolt_boss_04", + "HQF_L_case_bolt_boss_05", + "HQF_L_case_bolt_boss_06", + "HQF_L_case_bolt_boss_07", + "HQF_L_gear_casting_inner", + "HQF_L_gear_casting_outer", + "HQF_L_gear_neck_and_input_pocket", + "HQF_L_input_inspection_boss", + "HQF_L_inside_mounting_flange", + "HQF_L_intermediate_inspection_boss", + "HQF_L_longitudinal_fin_00", + "HQF_L_longitudinal_fin_01", + "HQF_L_longitudinal_fin_02", + "HQF_L_longitudinal_fin_03", + "HQF_L_longitudinal_fin_04", + "HQF_L_longitudinal_fin_05", + "HQF_L_longitudinal_fin_06", + "HQF_L_longitudinal_fin_07", + "HQF_L_longitudinal_fin_08", + "HQF_L_longitudinal_fin_09", + "HQF_L_longitudinal_fin_10", + "HQF_L_longitudinal_fin_11", + "HQF_L_longitudinal_fin_12", + "HQF_L_longitudinal_fin_13", + "HQF_L_longitudinal_fin_14", + "HQF_L_longitudinal_fin_15", + "HQF_L_longitudinal_fin_16", + "HQF_L_longitudinal_fin_17", + "HQF_L_longitudinal_fin_18", + "HQF_L_longitudinal_fin_19", + "HQF_L_longitudinal_fin_20", + "HQF_L_longitudinal_fin_21", + "HQF_L_longitudinal_fin_22", + "HQF_L_longitudinal_fin_23", + "HQF_L_longitudinal_fin_24", + "HQF_L_longitudinal_fin_25", + "HQF_L_longitudinal_fin_26", + "HQF_L_longitudinal_fin_27", + "HQF_L_longitudinal_fin_28", + "HQF_L_longitudinal_fin_29", + "HQF_L_longitudinal_fin_30", + "HQF_L_longitudinal_fin_31", + "HQF_L_longitudinal_fin_32", + "HQF_L_longitudinal_fin_33", + "HQF_L_longitudinal_fin_34", + "HQF_L_longitudinal_fin_35", + "HQF_L_longitudinal_fin_36", + "HQF_L_longitudinal_fin_37", + "HQF_L_longitudinal_fin_38", + "HQF_L_longitudinal_fin_39", + "HQF_L_motor_aluminum_body", + "HQF_L_motor_front_register", + "HQF_L_outside_mounting_flange", + "HQF_R_case_bolt_boss_00", + "HQF_R_case_bolt_boss_01", + "HQF_R_case_bolt_boss_02", + "HQF_R_case_bolt_boss_03", + "HQF_R_case_bolt_boss_04", + "HQF_R_case_bolt_boss_05", + "HQF_R_case_bolt_boss_06", + "HQF_R_case_bolt_boss_07", + "HQF_R_gear_casting_inner", + "HQF_R_gear_casting_outer", + "HQF_R_gear_neck_and_input_pocket", + "HQF_R_input_inspection_boss", + "HQF_R_inside_mounting_flange", + "HQF_R_intermediate_inspection_boss", + "HQF_R_longitudinal_fin_00", + "HQF_R_longitudinal_fin_01", + "HQF_R_longitudinal_fin_02", + "HQF_R_longitudinal_fin_03", + "HQF_R_longitudinal_fin_04", + "HQF_R_longitudinal_fin_05", + "HQF_R_longitudinal_fin_06", + "HQF_R_longitudinal_fin_07", + "HQF_R_longitudinal_fin_08", + "HQF_R_longitudinal_fin_09", + "HQF_R_longitudinal_fin_10", + "HQF_R_longitudinal_fin_11", + "HQF_R_longitudinal_fin_12", + "HQF_R_longitudinal_fin_13", + "HQF_R_longitudinal_fin_14", + "HQF_R_longitudinal_fin_15", + "HQF_R_longitudinal_fin_16", + "HQF_R_longitudinal_fin_17", + "HQF_R_longitudinal_fin_18", + "HQF_R_longitudinal_fin_19", + "HQF_R_longitudinal_fin_20", + "HQF_R_longitudinal_fin_21", + "HQF_R_longitudinal_fin_22", + "HQF_R_longitudinal_fin_23", + "HQF_R_longitudinal_fin_24", + "HQF_R_longitudinal_fin_25", + "HQF_R_longitudinal_fin_26", + "HQF_R_longitudinal_fin_27", + "HQF_R_longitudinal_fin_28", + "HQF_R_longitudinal_fin_29", + "HQF_R_longitudinal_fin_30", + "HQF_R_longitudinal_fin_31", + "HQF_R_longitudinal_fin_32", + "HQF_R_longitudinal_fin_33", + "HQF_R_longitudinal_fin_34", + "HQF_R_longitudinal_fin_35", + "HQF_R_longitudinal_fin_36", + "HQF_R_longitudinal_fin_37", + "HQF_R_longitudinal_fin_38", + "HQF_R_longitudinal_fin_39", + "HQF_R_motor_aluminum_body", + "HQF_R_motor_front_register", + "HQF_R_outside_mounting_flange" + ] + }, + "hardware": { + "name": "Чёрный крепёж", + "baseColorLinear": [ + 0.018, + 0.021, + 0.024 + ], + "metalness": 0.45, + "roughness": 0.38, + "sourceObjects": [ + "CASE | hinge knuckle", + "CASE | hinge knuckle.001", + "CASE | hinge knuckle.002", + "CASE | hinge knuckle.003", + "CASE | hinge knuckle.004", + "CASE | hinge knuckle.005", + "CASE | hinge screw", + "CASE | hinge screw.001", + "CASE | hinge screw.002", + "CASE | hinge screw.003", + "CASE | hinge screw.004", + "CASE | hinge screw.005", + "CASE | hinge screw.006", + "CASE | hinge screw.007", + "CASE | latch 0 hinge pin", + "CASE | latch 0 key barrel", + "CASE | latch 1 hinge pin", + "CASE | latch 1 key barrel", + "COMMS | COAX 1 SMA male shell", + "COMMS | COAX 1 SMA mouth", + "COMMS | COAX 1 SMA pin", + "COMMS | COAX 2 SMA male shell", + "COMMS | COAX 2 SMA mouth", + "COMMS | COAX 2 SMA pin", + "COMMS | COAX 3 SMA male shell", + "COMMS | COAX 3 SMA mouth", + "COMMS | COAX 3 SMA pin", + "COMMS | COAX 4 SMA male shell", + "COMMS | COAX 4 SMA mouth", + "COMMS | COAX 4 SMA pin", + "COMMS | COAX 5 SMA male shell", + "COMMS | COAX 5 SMA mouth", + "COMMS | COAX 5 SMA pin", + "COMMS | COAX 6 SMA male shell", + "COMMS | COAX 6 SMA mouth", + "COMMS | COAX 6 SMA pin", + "COMMS | COAX 7 SMA male shell", + "COMMS | COAX 7 SMA mouth", + "COMMS | COAX 7 SMA pin", + "COMMS | COAX 8 SMA male shell", + "COMMS | COAX 8 SMA mouth", + "COMMS | COAX 8 SMA pin", + "COMMS | ROUTER corner screw boss", + "COMMS | ROUTER corner screw boss.001", + "COMMS | ROUTER corner screw boss.002", + "COMMS | ROUTER corner screw boss.003", + "COMMS | ROUTER hinge pin", + "COMMS | ROUTER hinge pin.001", + "COMMS | ROUTER lid corner screw socket head", + "COMMS | ROUTER lid corner screw socket head.001", + "COMMS | ROUTER lid corner screw socket head.002", + "COMMS | ROUTER lid corner screw socket head.003", + "COMMS | ROUTER lid corner screw threaded shank", + "COMMS | ROUTER lid corner screw threaded shank.001", + "COMMS | ROUTER lid corner screw threaded shank.002", + "COMMS | ROUTER lid corner screw threaded shank.003", + "COMMS | ROUTER port 1 M22x1.5 thread", + "COMMS | ROUTER port 2 M22x1.5 thread", + "COMMS | ROUTER port 3 M22x1.5 thread", + "COMMS | ROUTER port 4 M22x1.5 thread", + "COMMS | ROUTER proposed mounting bolt", + "COMMS | ROUTER proposed mounting bolt.001", + "COMMS | ROUTER proposed mounting bolt.002", + "COMMS | ROUTER proposed mounting bolt.003", + "COMMS | ROUTER small lid face fastener", + "COMMS | ROUTER small lid face fastener.001", + "COMMS | ROUTER small lid face fastener.002", + "COMMS | ROUTER small lid face fastener.003", + "COMMS | SG1 aluminium base", + "COMMS | SG1 estimated mounting thread", + "COMMS | SG1 hollow stud - diameter and thread estimated", + "COMMS | SG1 inside backing washer", + "COMMS | SG1 inside locknut", + "COMMS | SG1 lower thread CONCEPT", + "COMMS | SG2 aluminium base", + "COMMS | SG2 estimated mounting thread", + "COMMS | SG2 hollow stud - diameter and thread estimated", + "COMMS | SG2 inside backing washer", + "COMMS | SG2 inside locknut", + "COMMS | SG2 lower thread CONCEPT", + "DRIVE | flange M8 hex bolt", + "DRIVE | flange M8 hex bolt.001", + "DRIVE | flange M8 hex bolt.002", + "DRIVE | flange M8 hex bolt.003", + "DRIVE | flange M8 hex bolt.004", + "DRIVE | flange M8 hex bolt.005", + "DRIVE | flange M8 hex bolt.006", + "DRIVE | flange M8 hex bolt.007", + "DRIVE | flange M8 hex bolt.008", + "DRIVE | flange M8 hex bolt.009", + "DRIVE | flange washer PCD72", + "DRIVE | flange washer PCD72.001", + "DRIVE | flange washer PCD72.002", + "DRIVE | flange washer PCD72.003", + "DRIVE | flange washer PCD72.004", + "DRIVE | flange washer PCD72.005", + "DRIVE | flange washer PCD72.006", + "DRIVE | flange washer PCD72.007", + "DRIVE | flange washer PCD72.008", + "DRIVE | flange washer PCD72.009", + "HQF_L_case_bolt_head_00", + "HQF_L_case_bolt_head_01", + "HQF_L_case_bolt_head_02", + "HQF_L_case_bolt_head_03", + "HQF_L_case_bolt_head_04", + "HQF_L_case_bolt_head_05", + "HQF_L_case_bolt_head_06", + "HQF_L_case_bolt_head_07", + "HQF_L_case_bolt_washer_00", + "HQF_L_case_bolt_washer_01", + "HQF_L_case_bolt_washer_02", + "HQF_L_case_bolt_washer_03", + "HQF_L_case_bolt_washer_04", + "HQF_L_case_bolt_washer_05", + "HQF_L_case_bolt_washer_06", + "HQF_L_case_bolt_washer_07", + "HQF_L_inside_pilot_42mm", + "HQF_L_motor_register_bolt", + "HQF_L_motor_register_bolt.001", + "HQF_L_motor_register_bolt.002", + "HQF_L_motor_register_bolt.003", + "HQF_L_oil_fill_hex", + "HQF_L_output_stub_PROXY", + "HQF_L_outside_pilot_42mm", + "HQF_R_case_bolt_head_00", + "HQF_R_case_bolt_head_01", + "HQF_R_case_bolt_head_02", + "HQF_R_case_bolt_head_03", + "HQF_R_case_bolt_head_04", + "HQF_R_case_bolt_head_05", + "HQF_R_case_bolt_head_06", + "HQF_R_case_bolt_head_07", + "HQF_R_case_bolt_washer_00", + "HQF_R_case_bolt_washer_01", + "HQF_R_case_bolt_washer_02", + "HQF_R_case_bolt_washer_03", + "HQF_R_case_bolt_washer_04", + "HQF_R_case_bolt_washer_05", + "HQF_R_case_bolt_washer_06", + "HQF_R_case_bolt_washer_07", + "HQF_R_inside_pilot_42mm", + "HQF_R_motor_register_bolt", + "HQF_R_motor_register_bolt.001", + "HQF_R_motor_register_bolt.002", + "HQF_R_motor_register_bolt.003", + "HQF_R_oil_fill_hex", + "HQF_R_output_stub_PROXY", + "HQF_R_outside_pilot_42mm", + "L DRIVE | central hub", + "L DRIVE | fixing hex", + "L DRIVE | fixing hex.001", + "L DRIVE | fixing hex.002", + "L DRIVE | fixing hex.003", + "L DRIVE | fixing hex.004", + "L DRIVE | fixing hex.005", + "L DRIVE | fixing hex.006", + "L DRIVE | fixing hex.007", + "L DRIVE | fixing hex.008", + "L DRIVE | fixing hex.009", + "L DRIVE | fixing hex.010", + "L DRIVE | fixing hex.011", + "L DRIVE | fixing hex.012", + "L DRIVE | fixing hex.013", + "L DRIVE | fixing hex.014", + "L DRIVE | fixing hex.015", + "L DRIVE | fixing hex.016", + "L DRIVE | fixing hex.017", + "L DRIVE | gearbox connecting shaft", + "L DRIVE | motor mounting hex", + "L DRIVE | motor mounting hex.001", + "L DRIVE | motor mounting hex.002", + "L DRIVE | motor mounting hex.003", + "L DRIVE | narrow coupling band", + "L DRIVE | shouldered shaft", + "L DRIVE | stack bolt head", + "L DRIVE | stack bolt head.001", + "L DRIVE | stack bolt head.002", + "L DRIVE | stack bolt head.003", + "L DRIVE | stack bolt head.004", + "L DRIVE | stack bolt head.005", + "L DRIVE | stack centre hex", + "L DRIVE | stack centre hex.001", + "L DRIVE | stack local bolt shank", + "L DRIVE | stack local bolt shank.001", + "L DRIVE | stack local bolt shank.002", + "L DRIVE | stack local bolt shank.003", + "L DRIVE | stack local bolt shank.004", + "L DRIVE | stack local bolt shank.005", + "L DRIVE | steel clamping flange", + "L DRIVE | steel clamping flange.001", + "L DRIVE | through fixing bolt", + "L DRIVE | through fixing bolt.001", + "L DRIVE | through fixing bolt.002", + "L DRIVE | through fixing bolt.003", + "L DRIVE | through fixing bolt.004", + "L DRIVE | through fixing bolt.005", + "L DRIVE | through fixing bolt.006", + "L DRIVE | through fixing bolt.007", + "L DRIVE | through fixing bolt.008", + "L REAR | sleeve hex end", + "L SHOCK | aluminium damper body", + "L SHOCK | body adjustment thread", + "L SHOCK | eye", + "L SHOCK | eye.001", + "L SHOCK | fixed bolt", + "L SHOCK | fixed bolt.001", + "L SHOCK | fixed transverse spacer", + "L SHOCK | inner sliding stem", + "L SHOCK | rocker bolt", + "L SHOCK | rocker bolt.001", + "L SHOCK | rocker transverse spacer", + "L SHOCK | six scallop preload collar", + "L SHOCK | spring lower seat", + "L SPRING_1 | guide stud", + "L SPRING_1 | lower anchor nut", + "L SPRING_1 | preload hex nut", + "L SPRING_1 | seating washer", + "L SPRING_1 | seating washer.001", + "L SPRING_1 | seating washer.002", + "L SPRING_1 | visible stud thread", + "L SPRING_2 | guide stud", + "L SPRING_2 | preload hex nut", + "L SPRING_2 | seating washer", + "L SPRING_2 | seating washer.001", + "L SPRING_2 | seating washer.002", + "L SPRING_2 | visible stud thread", + "L SPRING_3 | guide stud", + "L SPRING_3 | preload hex nut", + "L SPRING_3 | seating washer", + "L SPRING_3 | seating washer.001", + "L SPRING_3 | seating washer.002", + "L SPRING_3 | visible stud thread", + "L front_lower | fixed through pin", + "L front_lower | roller shaft", + "L front_lower | stack bolt head", + "L front_lower | stack bolt head.001", + "L front_lower | stack bolt head.002", + "L front_lower | stack bolt head.003", + "L front_lower | stack bolt head.004", + "L front_lower | stack bolt head.005", + "L front_lower | stack centre hex", + "L front_lower | stack centre hex.001", + "L front_lower | stack local bolt shank", + "L front_lower | stack local bolt shank.001", + "L front_lower | stack local bolt shank.002", + "L front_lower | stack local bolt shank.003", + "L front_lower | stack local bolt shank.004", + "L front_lower | stack local bolt shank.005", + "L middle_lower | fixed through pin", + "L middle_lower | roller shaft", + "L middle_lower | stack bolt head", + "L middle_lower | stack bolt head.001", + "L middle_lower | stack bolt head.002", + "L middle_lower | stack bolt head.003", + "L middle_lower | stack bolt head.004", + "L middle_lower | stack bolt head.005", + "L middle_lower | stack centre hex", + "L middle_lower | stack centre hex.001", + "L middle_lower | stack local bolt shank", + "L middle_lower | stack local bolt shank.001", + "L middle_lower | stack local bolt shank.002", + "L middle_lower | stack local bolt shank.003", + "L middle_lower | stack local bolt shank.004", + "L middle_lower | stack local bolt shank.005", + "L rear_lower | fixed through pin", + "L rear_lower | roller shaft", + "L rear_lower | stack bolt head", + "L rear_lower | stack bolt head.001", + "L rear_lower | stack bolt head.002", + "L rear_lower | stack bolt head.003", + "L rear_lower | stack bolt head.004", + "L rear_lower | stack bolt head.005", + "L rear_lower | stack centre hex", + "L rear_lower | stack centre hex.001", + "L rear_lower | stack local bolt shank", + "L rear_lower | stack local bolt shank.001", + "L rear_lower | stack local bolt shank.002", + "L rear_lower | stack local bolt shank.003", + "L rear_lower | stack local bolt shank.004", + "L rear_lower | stack local bolt shank.005", + "L rear_upper | fixed through pin", + "L rear_upper | roller shaft", + "L rear_upper | stack bolt head", + "L rear_upper | stack bolt head.001", + "L rear_upper | stack bolt head.002", + "L rear_upper | stack bolt head.003", + "L rear_upper | stack bolt head.004", + "L rear_upper | stack bolt head.005", + "L rear_upper | stack centre hex", + "L rear_upper | stack centre hex.001", + "L rear_upper | stack local bolt shank", + "L rear_upper | stack local bolt shank.001", + "L rear_upper | stack local bolt shank.002", + "L rear_upper | stack local bolt shank.003", + "L rear_upper | stack local bolt shank.004", + "L rear_upper | stack local bolt shank.005", + "L | UPPER hex spacer 35x25", + "L | hollow side fitting", + "L | hollow side fitting.001", + "L | side fitting hex", + "L | side fitting hex.001", + "MOLLE | screw drive", + "MOLLE | screw drive.001", + "MOLLE | screw drive.002", + "MOLLE | screw drive.003", + "R DRIVE | central hub", + "R DRIVE | fixing hex", + "R DRIVE | fixing hex.001", + "R DRIVE | fixing hex.002", + "R DRIVE | fixing hex.003", + "R DRIVE | fixing hex.004", + "R DRIVE | fixing hex.005", + "R DRIVE | fixing hex.006", + "R DRIVE | fixing hex.007", + "R DRIVE | fixing hex.008", + "R DRIVE | fixing hex.009", + "R DRIVE | fixing hex.010", + "R DRIVE | fixing hex.011", + "R DRIVE | fixing hex.012", + "R DRIVE | fixing hex.013", + "R DRIVE | fixing hex.014", + "R DRIVE | fixing hex.015", + "R DRIVE | fixing hex.016", + "R DRIVE | fixing hex.017", + "R DRIVE | gearbox connecting shaft", + "R DRIVE | motor mounting hex", + "R DRIVE | motor mounting hex.001", + "R DRIVE | motor mounting hex.002", + "R DRIVE | motor mounting hex.003", + "R DRIVE | narrow coupling band", + "R DRIVE | shouldered shaft", + "R DRIVE | stack bolt head", + "R DRIVE | stack bolt head.001", + "R DRIVE | stack bolt head.002", + "R DRIVE | stack bolt head.003", + "R DRIVE | stack bolt head.004", + "R DRIVE | stack bolt head.005", + "R DRIVE | stack centre hex", + "R DRIVE | stack centre hex.001", + "R DRIVE | stack local bolt shank", + "R DRIVE | stack local bolt shank.001", + "R DRIVE | stack local bolt shank.002", + "R DRIVE | stack local bolt shank.003", + "R DRIVE | stack local bolt shank.004", + "R DRIVE | stack local bolt shank.005", + "R DRIVE | steel clamping flange", + "R DRIVE | steel clamping flange.001", + "R DRIVE | through fixing bolt", + "R DRIVE | through fixing bolt.001", + "R DRIVE | through fixing bolt.002", + "R DRIVE | through fixing bolt.003", + "R DRIVE | through fixing bolt.004", + "R DRIVE | through fixing bolt.005", + "R DRIVE | through fixing bolt.006", + "R DRIVE | through fixing bolt.007", + "R DRIVE | through fixing bolt.008", + "R REAR | sleeve hex end", + "R SHOCK | aluminium damper body", + "R SHOCK | body adjustment thread", + "R SHOCK | eye", + "R SHOCK | eye.001", + "R SHOCK | fixed bolt", + "R SHOCK | fixed bolt.001", + "R SHOCK | fixed transverse spacer", + "R SHOCK | inner sliding stem", + "R SHOCK | rocker bolt", + "R SHOCK | rocker bolt.001", + "R SHOCK | rocker transverse spacer", + "R SHOCK | six scallop preload collar", + "R SHOCK | spring lower seat", + "R SPRING_1 | guide stud", + "R SPRING_1 | lower anchor nut", + "R SPRING_1 | preload hex nut", + "R SPRING_1 | seating washer", + "R SPRING_1 | seating washer.001", + "R SPRING_1 | seating washer.002", + "R SPRING_1 | visible stud thread", + "R SPRING_2 | guide stud", + "R SPRING_2 | preload hex nut", + "R SPRING_2 | seating washer", + "R SPRING_2 | seating washer.001", + "R SPRING_2 | seating washer.002", + "R SPRING_2 | visible stud thread", + "R SPRING_3 | guide stud", + "R SPRING_3 | preload hex nut", + "R SPRING_3 | seating washer", + "R SPRING_3 | seating washer.001", + "R SPRING_3 | seating washer.002", + "R SPRING_3 | visible stud thread", + "R front_lower | fixed through pin", + "R front_lower | roller shaft", + "R front_lower | stack bolt head", + "R front_lower | stack bolt head.001", + "R front_lower | stack bolt head.002", + "R front_lower | stack bolt head.003", + "R front_lower | stack bolt head.004", + "R front_lower | stack bolt head.005", + "R front_lower | stack centre hex", + "R front_lower | stack centre hex.001", + "R front_lower | stack local bolt shank", + "R front_lower | stack local bolt shank.001", + "R front_lower | stack local bolt shank.002", + "R front_lower | stack local bolt shank.003", + "R front_lower | stack local bolt shank.004", + "R front_lower | stack local bolt shank.005", + "R middle_lower | fixed through pin", + "R middle_lower | roller shaft", + "R middle_lower | stack bolt head", + "R middle_lower | stack bolt head.001", + "R middle_lower | stack bolt head.002", + "R middle_lower | stack bolt head.003", + "R middle_lower | stack bolt head.004", + "R middle_lower | stack bolt head.005", + "R middle_lower | stack centre hex", + "R middle_lower | stack centre hex.001", + "R middle_lower | stack local bolt shank", + "R middle_lower | stack local bolt shank.001", + "R middle_lower | stack local bolt shank.002", + "R middle_lower | stack local bolt shank.003", + "R middle_lower | stack local bolt shank.004", + "R middle_lower | stack local bolt shank.005", + "R rear_lower | fixed through pin", + "R rear_lower | roller shaft", + "R rear_lower | stack bolt head", + "R rear_lower | stack bolt head.001", + "R rear_lower | stack bolt head.002", + "R rear_lower | stack bolt head.003", + "R rear_lower | stack bolt head.004", + "R rear_lower | stack bolt head.005", + "R rear_lower | stack centre hex", + "R rear_lower | stack centre hex.001", + "R rear_lower | stack local bolt shank", + "R rear_lower | stack local bolt shank.001", + "R rear_lower | stack local bolt shank.002", + "R rear_lower | stack local bolt shank.003", + "R rear_lower | stack local bolt shank.004", + "R rear_lower | stack local bolt shank.005", + "R rear_upper | fixed through pin", + "R rear_upper | roller shaft", + "R rear_upper | stack bolt head", + "R rear_upper | stack bolt head.001", + "R rear_upper | stack bolt head.002", + "R rear_upper | stack bolt head.003", + "R rear_upper | stack bolt head.004", + "R rear_upper | stack bolt head.005", + "R rear_upper | stack centre hex", + "R rear_upper | stack centre hex.001", + "R rear_upper | stack local bolt shank", + "R rear_upper | stack local bolt shank.001", + "R rear_upper | stack local bolt shank.002", + "R rear_upper | stack local bolt shank.003", + "R rear_upper | stack local bolt shank.004", + "R rear_upper | stack local bolt shank.005", + "R | UPPER hex spacer 35x25", + "R | hollow side fitting", + "R | hollow side fitting.001", + "R | side fitting hex", + "R | side fitting hex.001", + "VAN004 | CAMERA captive nut socket head", + "VAN004 | CAMERA captive nut threaded shank", + "VAN004 | CAMERA carriage screw socket head", + "VAN004 | CAMERA carriage screw socket head.001", + "VAN004 | CAMERA carriage screw threaded shank", + "VAN004 | CAMERA carriage screw threaded shank.001", + "VAN004 | CAMERA clamp crossbolt", + "VAN004 | CAMERA elongated two-bolt track carriage", + "VAN004 | CAMERA front T-nut", + "VAN004 | DEVICE captive nut socket head", + "VAN004 | DEVICE captive nut threaded shank", + "VAN004 | DEVICE carriage screw socket head", + "VAN004 | DEVICE carriage screw socket head.001", + "VAN004 | DEVICE carriage screw threaded shank", + "VAN004 | DEVICE carriage screw threaded shank.001", + "VAN004 | DEVICE clamp crossbolt", + "VAN004 | DEVICE elongated two-bolt track carriage", + "VAN004 | DEVICE front T-nut", + "VAN004 | GoPro acorn nut", + "VAN004 | GoPro through bolt", + "VAN004 | adapter lid screw socket head", + "VAN004 | adapter lid screw socket head.001", + "VAN004 | adapter lid screw threaded shank", + "VAN004 | adapter lid screw threaded shank.001", + "VAN004 | end sleeve countersunk bolt socket head", + "VAN004 | end sleeve countersunk bolt socket head.001", + "VAN004 | end sleeve countersunk bolt threaded shank", + "VAN004 | end sleeve countersunk bolt threaded shank.001", + "VAN004 | outer underside fastener socket head", + "VAN004 | outer underside fastener socket head.001", + "VAN004 | outer underside fastener socket head.002", + "VAN004 | outer underside fastener socket head.003" + ] + }, + "polymer": { + "name": "Пластик и кожухи", + "baseColorLinear": [ + 0.018, + 0.022, + 0.026 + ], + "metalness": 0, + "roughness": 0.44, + "sourceObjects": [ + "COMMS | SG1 ASA radome D96 H102", + "COMMS | SG2 ASA radome D96 H102", + "HQF_L_black_fan_cover", + "HQF_L_cable_gland", + "HQF_L_cable_gland.001", + "HQF_L_fan_cover_mount", + "HQF_L_fan_cover_mount.001", + "HQF_L_fan_cover_mount.002", + "HQF_L_fan_cover_mount.003", + "HQF_L_fan_grid_horizontal", + "HQF_L_fan_grid_horizontal.001", + "HQF_L_fan_grid_horizontal.002", + "HQF_L_fan_grid_horizontal.003", + "HQF_L_fan_grid_horizontal.004", + "HQF_L_fan_grid_horizontal.005", + "HQF_L_fan_grid_horizontal.006", + "HQF_L_fan_grid_horizontal.007", + "HQF_L_fan_grid_horizontal.008", + "HQF_L_fan_grid_horizontal.009", + "HQF_L_fan_grid_horizontal.010", + "HQF_L_fan_grid_horizontal.011", + "HQF_L_fan_grid_horizontal.012", + "HQF_L_fan_grid_horizontal.013", + "HQF_L_fan_grid_horizontal.014", + "HQF_L_fan_grid_horizontal.015", + "HQF_L_fan_grid_horizontal.016", + "HQF_L_fan_grid_horizontal.017", + "HQF_L_fan_grid_horizontal.018", + "HQF_L_fan_grid_horizontal.019", + "HQF_L_fan_grid_horizontal.020", + "HQF_L_fan_grid_vertical", + "HQF_L_fan_grid_vertical.001", + "HQF_L_fan_grid_vertical.002", + "HQF_L_fan_grid_vertical.003", + "HQF_L_fan_grid_vertical.004", + "HQF_L_fan_grid_vertical.005", + "HQF_L_fan_grid_vertical.006", + "HQF_L_fan_grid_vertical.007", + "HQF_L_fan_grid_vertical.008", + "HQF_L_fan_grid_vertical.009", + "HQF_L_fan_grid_vertical.010", + "HQF_L_fan_grid_vertical.011", + "HQF_L_fan_grid_vertical.012", + "HQF_L_fan_grid_vertical.013", + "HQF_L_fan_grid_vertical.014", + "HQF_L_fan_grid_vertical.015", + "HQF_L_fan_grid_vertical.016", + "HQF_L_fan_grid_vertical.017", + "HQF_L_fan_grid_vertical.018", + "HQF_L_fan_grid_vertical.019", + "HQF_L_fan_grid_vertical.020", + "HQF_L_fan_shadow", + "HQF_L_fan_vent_lip", + "HQF_L_gear_parting_seam", + "HQF_L_inside_shaft_seal", + "HQF_L_outside_shaft_seal", + "HQF_R_black_fan_cover", + "HQF_R_cable_gland", + "HQF_R_cable_gland.001", + "HQF_R_fan_cover_mount", + "HQF_R_fan_cover_mount.001", + "HQF_R_fan_cover_mount.002", + "HQF_R_fan_cover_mount.003", + "HQF_R_fan_grid_horizontal", + "HQF_R_fan_grid_horizontal.001", + "HQF_R_fan_grid_horizontal.002", + "HQF_R_fan_grid_horizontal.003", + "HQF_R_fan_grid_horizontal.004", + "HQF_R_fan_grid_horizontal.005", + "HQF_R_fan_grid_horizontal.006", + "HQF_R_fan_grid_horizontal.007", + "HQF_R_fan_grid_horizontal.008", + "HQF_R_fan_grid_horizontal.009", + "HQF_R_fan_grid_horizontal.010", + "HQF_R_fan_grid_horizontal.011", + "HQF_R_fan_grid_horizontal.012", + "HQF_R_fan_grid_horizontal.013", + "HQF_R_fan_grid_horizontal.014", + "HQF_R_fan_grid_horizontal.015", + "HQF_R_fan_grid_horizontal.016", + "HQF_R_fan_grid_horizontal.017", + "HQF_R_fan_grid_horizontal.018", + "HQF_R_fan_grid_horizontal.019", + "HQF_R_fan_grid_horizontal.020", + "HQF_R_fan_grid_vertical", + "HQF_R_fan_grid_vertical.001", + "HQF_R_fan_grid_vertical.002", + "HQF_R_fan_grid_vertical.003", + "HQF_R_fan_grid_vertical.004", + "HQF_R_fan_grid_vertical.005", + "HQF_R_fan_grid_vertical.006", + "HQF_R_fan_grid_vertical.007", + "HQF_R_fan_grid_vertical.008", + "HQF_R_fan_grid_vertical.009", + "HQF_R_fan_grid_vertical.010", + "HQF_R_fan_grid_vertical.011", + "HQF_R_fan_grid_vertical.012", + "HQF_R_fan_grid_vertical.013", + "HQF_R_fan_grid_vertical.014", + "HQF_R_fan_grid_vertical.015", + "HQF_R_fan_grid_vertical.016", + "HQF_R_fan_grid_vertical.017", + "HQF_R_fan_grid_vertical.018", + "HQF_R_fan_grid_vertical.019", + "HQF_R_fan_grid_vertical.020", + "HQF_R_fan_shadow", + "HQF_R_fan_vent_lip", + "HQF_R_gear_parting_seam", + "HQF_R_inside_shaft_seal", + "HQF_R_outside_shaft_seal", + "L DRIVE | stepped coupling exterior", + "L SHOCK | black label band", + "L SHOCK | eye bushing", + "L SHOCK | eye bushing.001", + "MOTOR | rear cover harness", + "MOTOR | rear cover harness.001", + "R DRIVE | stepped coupling exterior", + "R SHOCK | black label band", + "R SHOCK | eye bushing", + "R SHOCK | eye bushing.001", + "VAN004 | CAMERA clamp hand knob", + "VAN004 | DEVICE clamp hand knob", + "VAN004 | GoPro end grip", + "VAN004 | GoPro three-finger lug", + "VAN004 | GoPro three-finger lug.001", + "VAN004 | GoPro three-finger lug.002", + "VAN004 | GoPro tightening lever", + "VAN004 | device end hook", + "VAN004 | device end hook.001", + "VAN004 | device end hook.002", + "VAN004 | device end hook.003", + "VAN004 | device jaw with vertical capsule aperture", + "VAN004 | device jaw with vertical capsule aperture.001", + "VAN004 | device retaining rolled lip", + "VAN004 | device retaining rolled lip.001", + "VAN004 | landscape holder back" + ] + }, + "drive": { + "name": "Ведущие звёзды", + "baseColorLinear": [ + 0.22, + 0.13, + 0.038 + ], + "metalness": 0, + "roughness": 0.46, + "sourceObjects": [ + "L DRIVE | 9 tooth Buran-type drive ring", + "R DRIVE | 9 tooth Buran-type drive ring" + ] + } + } } diff --git a/apps/control-station/src/components/rover/RoverView.tsx b/apps/control-station/src/components/rover/RoverView.tsx index 7759f4d..fb557a6 100644 --- a/apps/control-station/src/components/rover/RoverView.tsx +++ b/apps/control-station/src/components/rover/RoverView.tsx @@ -6,40 +6,41 @@ import {bindRoverHoldInput} from '../../core/fleet/roverHoldInput'; import type {RoverController} from '../../core/fleet/useRoverControl'; import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation'; import './rover.css'; -const Scene=lazy(()=>import('./playcanvas/PlayCanvasViewer')); +const Scene=lazy(()=>import('./editor/RoverScene')); const labels:Record={observing:'Наблюдение',preparing:'Подготовка управления',ready:'Управление включено',driving:'Команда движения',receiver:'Управление с пульта',stopping:'Остановка',stopped:'Управление выключено',fault:'Управление остановлено'}; export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){ const storage=`missioncore.rover-view.v1:${vehicleID}`; const [settings,setSettings]=useState(()=>{try{return roverSettings(JSON.parse(localStorage.getItem(storage)??'null'));}catch{return roverSettings(null);}}); const [open,setOpen]=useState(false),[confirmed,setConfirmed]=useState(false),[held,setHeld]=useState>(new Set()); + const [editing,setEditing]=useState(false); const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading'); const stage=useRef(null),input=useRef|null>(null); const stop=useCallback(()=>{input.current?.dispose();input.current=null;c.setInputGuard(null);setHeld(new Set());c.stop();setConfirmed(false);},[c.stop,c.setInputGuard]); useEffect(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]); useEffect(()=>{if(!active)stop();},[active,stop]); useEffect(()=>{ - if(!c.ready||!active||open||!stage.current)return; + if(!c.ready||!active||open||editing||!stage.current)return; const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop}); input.current=binding;c.setInputGuard(binding.valid); return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;}; - },[c.ready,active,open,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]); - const showSettings=()=>{stop();setOpen(true);}; + },[c.ready,active,open,editing,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]); + const showSettings=()=>{setEditing(false);stop();setOpen(true);}; const preparing=c.pending||(c.armed&&!c.ready); const availability=c.connecting?'Синхронизация с бортом':!c.state.fresh?'Нет свежих данных с борта':!c.state.snapshot.supported?'Управление на борту недоступно':c.state.controlling&&!c.armed?'Управление уже включено':!c.canArm&&!c.armed?'Завершение предыдущего управления':null; const tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral'; const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение'); return <> - {createPortal(,header.actionsTarget)} + {createPortal(<>{c.pauseInput();setHeld(new Set());setEditing(v=>!v);}}>,header.actionsTarget)} {createPortal(,header.statusTarget)}
{if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}> {settings.model==='dcd006-v020'? - + {modelState==='error'&&

Не удалось загрузить модель ровера.

}
:

Выберите модель аппарата.

} -
{status}
- {c.ready&&
+
{editing?'Редактирование сцены':status}
+ {c.ready&&!editing&&
{keysFor(settings.mode).map(code=> {settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп
} -
+ {!editing&&
{c.armed||c.pending?:} -
+
}
setOpen(false)} title="Управление ровером" size="md">
{const f=e.currentTarget.files?.[0];if(f)onImport(f);e.currentTarget.value='';}}/>
; +} diff --git a/apps/control-station/src/components/rover/editor/LutField.tsx b/apps/control-station/src/components/rover/editor/LutField.tsx new file mode 100644 index 0000000..4f3ccce --- /dev/null +++ b/apps/control-station/src/components/rover/editor/LutField.tsx @@ -0,0 +1,7 @@ +import {useRef,useState} from 'react'; +import {Button,Icon,IconButton} from '@nodedc/ui-react'; +import {textureFromFile} from '../../../core/roverScene/sceneStorage'; +export function LutField({value,onChange,onError}:{value:string|null|undefined;onChange:(value:string|null)=>void;onError:(error:string)=>void}){ + const input=useRef(null),[loading,setLoading]=useState(false); + return

PNG 256 × 16 · горизонтальная таблица 16³

{value&&onChange(null)}>}
{const file=e.currentTarget.files?.[0];e.currentTarget.value='';if(!file)return;setLoading(true);try{const bitmap=await createImageBitmap(file);const valid=bitmap.width===256&&bitmap.height===16;bitmap.close();if(!valid||file.type!=='image/png')throw new Error('Нужна PNG-таблица LUT размером 256 × 16.');onChange((await textureFromFile(file)).data);}catch(error){onError(error instanceof Error?error.message:'Не удалось прочитать LUT.');}finally{setLoading(false);}}}/>
; +} diff --git a/apps/control-station/src/components/rover/editor/MaterialInspector.tsx b/apps/control-station/src/components/rover/editor/MaterialInspector.tsx new file mode 100644 index 0000000..16d2b87 --- /dev/null +++ b/apps/control-station/src/components/rover/editor/MaterialInspector.tsx @@ -0,0 +1,17 @@ +import {useRef,useState} from 'react'; +import {Button,ColorField,ControlRow,Icon,IconButton,Inspector,RangeControl,Switch,TextField} from '@nodedc/ui-react'; +import {textureSlots,type TextureSlot,type MaterialSpec} from '../../../core/roverScene/sceneDocument'; +import {textureFromFile} from '../../../core/roverScene/sceneStorage'; +const slotLabels:Record={diffuse:'Базовый цвет',normal:'Нормали',roughness:'Шероховатость',metalness:'Металличность',opacity:'Прозрачность',emissive:'Свечение',ao:'Окклюзия · AO'}; +export function MaterialInspector({material,onChange,onReset,onError}:{material:MaterialSpec;onChange:(patch:Partial)=>void;onReset:()=>void;onError:(message:string)=>void}){ + const range=(key:keyof MaterialSpec,label:string,max=1)=>onChange({[key]:v})}/>; + return <>onChange({name:e.target.value})}/>

Изменения применяются ко всем деталям с этим материалом.

onChange({color})}/>{range('metalness','Металличность')}{range('roughness','Шероховатость')}{range('reflectivity','Отражение')}{range('opacity','Непрозрачность')}onChange({doubleSided})}/>
}, + {id:'coat',label:'Лак и свечение',content:
{range('clearCoat','Слой лака')}{range('clearCoatRoughness','Шероховатость лака')}onChange({emissive})}/>{range('emissiveIntensity','Сила свечения',10)}
}, + {id:'textures',label:'Текстуры',description:'PNG / JPEG / WebP · до 4096 px',content:
{textureSlots.map(slot=>onChange({textures:{...material.textures,[slot]:texture}})} onError={onError}/>)}{range('normalStrength','Сила нормалей',2)}onChange({tiling})}/>
}, + ]}/>; +} +function TextureField({slot,value,onChange,onError}:{slot:TextureSlot;value:MaterialSpec['textures'][TextureSlot];onChange:(value:MaterialSpec['textures'][TextureSlot])=>void;onError:(message:string)=>void}){ + const input=useRef(null),[pending,setPending]=useState(false); + return
{value&&}{value&&onChange(undefined)}>}
{const file=e.currentTarget.files?.[0];e.currentTarget.value='';if(!file)return;setPending(true);try{onChange(await textureFromFile(file));}catch(error){onError(error instanceof Error?error.message:'Не удалось загрузить текстуру.');}finally{setPending(false);}}}/>
; +} diff --git a/apps/control-station/src/components/rover/editor/RoverScene.tsx b/apps/control-station/src/components/rover/editor/RoverScene.tsx new file mode 100644 index 0000000..74bf5a1 --- /dev/null +++ b/apps/control-station/src/components/rover/editor/RoverScene.tsx @@ -0,0 +1,38 @@ +import {useCallback,useEffect,useState,type CSSProperties} from 'react'; +import {Button,GlassSurface,Icon,IconButton,LoadingRegion,StatusBadge,ToastStack,Window} from '@nodedc/ui-react'; +import {assignMaterial,createMaterial,changeField,defaultScene,textureBytes,type MaterialSpec} from '../../../core/roverScene/sceneDocument'; +import {useSceneDocument} from '../../../core/roverScene/useSceneDocument'; +import PlayCanvasViewer from '../playcanvas/PlayCanvasViewer'; +import {EnvironmentInspector,SceneFileActions} from './EnvironmentInspector'; +import {MaterialInspector} from './MaterialInspector'; +import './sceneEditor.css'; +export default function RoverScene({vehicleID,editing,onModelState}:{vehicleID:string;editing:boolean;onModelState:(state:'loading'|'ready'|'error')=>void}){ + const scene=useSceneDocument(vehicleID),[palette,setPalette]=useState(false),[environment,setEnvironment]=useState(false),[selected,setSelected]=useState(null),[materialOpen,setMaterialOpen]=useState(false),[cameraReset,setCameraReset]=useState(0),[textureState,setTextureState]=useState<'loading'|'ready'|'error'>('ready'); + const [selectedGroup,setSelectedGroup]=useState(null); + const pick=useCallback((id:string,group?:string)=>{setSelected(id);if(group)setSelectedGroup(group);setMaterialOpen(true);},[]); + const addMaterial=()=>{if(Object.keys(scene.document.materials).length>=128){scene.setError('В сцене может быть не больше 128 материалов.');return;}const id=`custom/${crypto.randomUUID()}`;scene.update(doc=>createMaterial(doc,id));pick(id);}; + const patchMaterial=(id:string,patch:Partial)=>{const next={...scene.document,materials:{...scene.document.materials,[id]:{...scene.document.materials[id],...patch}}};if(textureBytes(next)>28_000_000){scene.setError('Суммарный объём текстур превышает 20 МБ. Удалите ненужную текстуру.');return;}scene.update(doc=>({...doc,materials:{...doc.materials,[id]:{...doc.materials[id],...patch}}}));}; + useEffect(()=>{if(textureState==='error')scene.setError('Не удалось применить текстуру к сцене. Проверьте файл и загрузите его заново.');},[textureState,scene.setError]); + const saved=scene.saveState==='saved',status=textureState==='loading'?'Загрузка текстур':scene.saveState==='loading'?'Загрузка настроек сцены':scene.saveState==='saving'?'Сохранение сцены':scene.saveState==='error'?'Изменения не сохранены':'Сохранено на этом компьютере'; + const material=selected?scene.document.materials[selected]:null; + return <> + + {scene.ready&&} + + {editing&&<> +
setPalette(v=>!v)}>setEnvironment(v=>!v)}>
+
{status}
+ {palette&&
Материалы · выберите в палитре или на моделиsetPalette(false)}>
{Object.entries(scene.document.materials).map(([id,m])=>)}
} + } + setEnvironment(false)} footer={scene.update(doc=>({...doc,postFx:defaultScene().postFx}))}/>}> + scene.update(doc=>changeField(doc,key,value))} onResetCamera={()=>setCameraReset(v=>v+1)}/> + + setMaterialOpen(false)}> + {material&&selected&&<> + {selectedGroup?

Выбранная группа: {defaultScene().materials[selectedGroup]?.name}

:

Нажмите на деталь в сцене, затем выберите материал в палитре, чтобы назначить его группе.

} + patchMaterial(selected,patch)} onReset={()=>{const base=defaultScene().materials[selected]??createMaterial(defaultScene(),selected).materials[selected];if(base)patchMaterial(selected,{...base,name:material.name});}} onError={scene.setError}/> + } +
+ scene.setError('')}/> + ; +} diff --git a/apps/control-station/src/components/rover/editor/sceneEditor.css b/apps/control-station/src/components/rover/editor/sceneEditor.css new file mode 100644 index 0000000..836e1cd --- /dev/null +++ b/apps/control-station/src/components/rover/editor/sceneEditor.css @@ -0,0 +1,17 @@ +.rover-scene-tools {position:absolute;right:var(--nodedc-space-3);top:calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3));} +.rover-scene-tools__surface,.rover-scene-actions {display:flex;align-items:center;gap:var(--nodedc-space-2);flex-wrap:wrap;} +.rover-scene-tools__surface {padding:var(--nodedc-space-1);} +.rover-scene-save {position:absolute;left:var(--nodedc-space-3);top:calc(var(--observation-overlay-header-space, 0px) + 3rem);pointer-events:none;} +.rover-material-palette {position:absolute;bottom:var(--nodedc-space-3);left:var(--nodedc-space-3);right:var(--nodedc-space-3);padding:var(--nodedc-space-2);} +.rover-material-palette__header {display:flex;align-items:center;justify-content:space-between;gap:var(--nodedc-space-2);font-size:var(--nodedc-font-size-xs);color:var(--nodedc-text-secondary);} +.rover-material-palette__items {display:flex;gap:var(--nodedc-space-2);overflow:auto;} +.rover-material-palette__items > * {flex:0 0 auto;} +.rover-material-tile {display:grid;justify-items:center;gap:var(--nodedc-space-1);width:100px;white-space:normal;font-size:var(--nodedc-font-size-xs);} +/* Domain material thumbnail: a swatch, not a replacement control. */ +.rover-material-swatch {display:block;width:38px;height:38px;border-radius:50%;background:radial-gradient(ellipse at 35% 25%,#ffffffb0,transparent 45%),radial-gradient(ellipse at 60% 75%,transparent,var(--material-color) 70%),var(--material-color);box-shadow:inset -5px -5px 12px #0008;} +.rover-scene-fields {display:flex;flex-direction:column;gap:var(--nodedc-space-3);} +.rover-scene-note {font-size:var(--nodedc-font-size-sm);color:var(--nodedc-text-secondary);} +.rover-scene-texture__row {display:flex;gap:var(--nodedc-space-2);align-items:center;min-width:0;} +.rover-scene-texture__row img {width:36px;height:36px;object-fit:cover;} +.rover-scene-texture__row .nodedc-button {min-width:0;overflow:hidden;} +@container(max-width:460px){.rover-scene-save{top:calc(var(--observation-overlay-header-space, 0px) + 4.5rem)}.rover-material-tile{width:82px}.rover-material-palette__header>span{max-width:210px}} diff --git a/apps/control-station/src/components/rover/playcanvas/PROVENANCE.json b/apps/control-station/src/components/rover/playcanvas/PROVENANCE.json index 53ba8be..7f1f416 100644 --- a/apps/control-station/src/components/rover/playcanvas/PROVENANCE.json +++ b/apps/control-station/src/components/rover/playcanvas/PROVENANCE.json @@ -11,6 +11,13 @@ "No fallback to a different vehicle", "Removed unused node-editor type helper; rendering settings unchanged", "Observe the host pane on resize; preserve responsive canvas CSS sizing", - "Frame the complete rover using the donor 28-degree FOV and current viewport aspect" - ] + "Frame the complete rover using the donor 28-degree FOV and current viewport aspect", + "Scene editing uses canonical modeless Design Guideline inspectors; all 109 donor camera/post-FX fields retained as a versioned field catalog", + "Original NodeDC environment preset restored by owner request; editor reset changes environment only", + "Semantic material groups, UV texture editing, GPU surface picking and bounded texture disposal", + "Live grid step/diameter and ambient fill added; wireframe application and LUT loading corrected", + "Responsive scene framing and palette space without drive authority" + ], + "inspector_source_sha256": "8f1df16bd8c12fb1b8481480690ff4ed0e6b2645cb3fba527888c0f9acb59192", + "model_revision": "dcd006-v020-materials-v2" } diff --git a/apps/control-station/src/components/rover/playcanvas/PlayCanvasViewer.tsx b/apps/control-station/src/components/rover/playcanvas/PlayCanvasViewer.tsx index dc639ae..5c52d37 100644 --- a/apps/control-station/src/components/rover/playcanvas/PlayCanvasViewer.tsx +++ b/apps/control-station/src/components/rover/playcanvas/PlayCanvasViewer.tsx @@ -3,13 +3,16 @@ import * as pc from 'playcanvas' import { buildSceneTree } from './sceneTree' import { mergePostFxDefaults, type PostFxSettings } from './playcanvasPostFx' +import {useLutTexture} from './useLutTexture' +import {useSceneMaterials,type SceneMaterialsProps} from './useSceneMaterials' + const DEFAULT_MODEL_URL = '' const PIXELFORMAT_RGBA8 = (pc as any).PIXELFORMAT_RGBA8 ?? 7 const PIXELFORMAT_111110F = (pc as any).PIXELFORMAT_111110F ?? 18 const PIXELFORMAT_RGBA16F = (pc as any).PIXELFORMAT_RGBA16F ?? 12 const PIXELFORMAT_RGBA32F = (pc as any).PIXELFORMAT_RGBA32F ?? 14 -export type PlayCanvasViewerProps = { +export type PlayCanvasViewerProps = SceneMaterialsProps & { modelUrl: string | null postFx?: Partial | null viewportSize?: { width: number; height: number } @@ -390,16 +393,9 @@ function toColor(value: ColorInput, fallback?: pc.Color): pc.Color { } function setWireframeForScene(app: pc.Application, enabled: boolean) { - const renders = app.root.findComponents('render') as any[] - for (const r of renders) { - const meshInstances = r?.meshInstances ?? [] - for (const mi of meshInstances) { - const mat = mi.material as any - if (mat && 'wireframe' in mat) { - mat.wireframe = enabled - mat.update?.() - } - } + const root = app.root.findByName('ModelRoot') as pc.Entity | null + for (const mi of collectMeshInstances(root)) { + mi.renderStyle = enabled ? pc.RENDERSTYLE_WIREFRAME : pc.RENDERSTYLE_SOLID } } @@ -805,7 +801,7 @@ function applyPostFx( } frame.rendering.renderTargetScale = fx.rendering.renderTargetScale frame.rendering.samples = Number.isFinite(overrides?.samples) ? Number(overrides?.samples) : fx.rendering.samples - frame.rendering.sceneColorMap = true + frame.rendering.sceneColorMap = !!fx.rendering.sceneColorMap frame.rendering.sceneDepthMap = !!fx.rendering.sceneDepthMap frame.rendering.toneMapping = fx.rendering.toneMapping frame.rendering.sharpness = fx.rendering.sharpness @@ -882,6 +878,7 @@ function applyPostFx( export default function PlayCanvasViewer({ modelUrl, + materials, assignments, editing, onMaterialPick, onTextureState, postFx, viewportSize, onSceneReady, @@ -928,7 +925,11 @@ export default function PlayCanvasViewer({ const [skyboxVersion, setSkyboxVersion] = useState(0) const [modelVersion, setModelVersion] = useState(0) + useSceneMaterials(appRef,cameraEntityRef,canvasRef,modelVersion,{materials,assignments,editing,onMaterialPick,onTextureState}) + const fx = useMemo(() => mergePostFxDefaults(postFx), [postFx]) + const lutError = useCallback(()=>onTextureState?.('error'),[onTextureState]) + useLutTexture(appRef,cameraFrameRef,lutTextureRef,fx.lut.textureUrl,appReady,lutError) useEffect(() => { cameraStateRef.current = cameraState ?? null @@ -962,7 +963,7 @@ export default function PlayCanvasViewer({ if (bounds && orbit) { const vertical = (cameraEntityRef.current?.camera?.fov ?? 28) * Math.PI / 360 const horizontal = Math.atan(Math.tan(vertical) * w / h) - orbit.setDistance(Math.max(1.8, bounds.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 1.1)) + orbit.setDistance(Math.max(1.8, bounds.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 0.88)) } }, []) @@ -1112,6 +1113,8 @@ export default function PlayCanvasViewer({ `, fragmentGLSL: ` uniform vec2 uHalfExtents; + uniform float uGridStep; + uniform float uGridRadius; uniform vec3 uColorX; uniform vec3 uColorZ; uniform vec3 uColorMain; @@ -1182,7 +1185,9 @@ export default function PlayCanvasViewer({ void main(void) { vec2 uv = uv0; - vec2 pos = (uv * 2.0 - 1.0) * uHalfExtents; + vec2 worldPos = (uv * 2.0 - 1.0) * uHalfExtents; + if (length(worldPos) > uGridRadius) discard; + vec2 pos = worldPos / uGridStep; vec2 ddx = dFdx(pos); vec2 ddy = dFdy(pos); @@ -1262,7 +1267,7 @@ export default function PlayCanvasViewer({ float fadeEnd = max(0.0, uFadeEnd); float fade = 1.0; if (fadeEnd > fadeStart && fadeEnd > 0.0) { - float d = length(pos); + float d = length(worldPos); fade = 1.0 - smoothstep(fadeStart, fadeEnd, d); } outAlpha *= fade; @@ -1312,6 +1317,8 @@ export default function PlayCanvasViewer({ gridMaterial.setParameter('uFadeStart', 0) gridMaterial.setParameter('uFadeEnd', 0) gridMaterial.setParameter('uResolution', 2) + gridMaterial.setParameter('uGridStep', fx.grid.step) + gridMaterial.setParameter('uGridRadius', fx.grid.diameter / 2) const gridHalfExtents = new pc.Vec2(500, 500) gridMaterial.setParameter('uHalfExtents', [gridHalfExtents.x, gridHalfExtents.y]) gridMaterial.update() @@ -1528,7 +1535,7 @@ export default function PlayCanvasViewer({ const cam = cameraEntityRef.current?.camera const vertical = (cam?.fov ?? 28) * Math.PI / 360 const horizontal = Math.atan(Math.tan(vertical) * (cam?.aspectRatio ?? 1)) - orbit.setDistance(Math.max(1.8, aabb.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 1.1)) + orbit.setDistance(Math.max(1.8, aabb.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 0.88)) } if (exposeSceneGraph || onSceneGraph) { @@ -1617,7 +1624,7 @@ export default function PlayCanvasViewer({ if (!aabb) return orbit.setState({ pivot: { x: aabb.center.x, y: aabb.center.y, z: aabb.center.z }, - distance: Math.max(1.8, aabb.halfExtents.length() * 2.5), + distance: Math.max(1.8,aabb.halfExtents.length() / Math.sin(Math.min(14*Math.PI/180,Math.atan(Math.tan(14*Math.PI/180)*(cameraEntityRef.current?.camera?.aspectRatio??1))))*.88), azimuthDeg: 45, elevationDeg: 10, }) @@ -1638,6 +1645,9 @@ export default function PlayCanvasViewer({ const gridVisible = !!fx.grid.enabled || !!fx.grid.dotsEnabled || !!fx.grid.crossEnabled grid.enabled = gridVisible + grid.setLocalScale(fx.grid.diameter,1,fx.grid.diameter) + mat.setParameter('uGridStep',fx.grid.step) + mat.setParameter('uGridRadius',fx.grid.diameter/2) const cX = toColor(fx.grid.colorX) const cZ = toColor(fx.grid.colorZ) const cMain = toColor(fx.grid.colorMain) @@ -1661,6 +1671,7 @@ export default function PlayCanvasViewer({ mat.setParameter('uFadeEnd', Math.max(0, Number(fx.grid.fadeEnd) || 0)) mat.update() }, [ + fx.grid.step,fx.grid.diameter, fx.grid.enabled, fx.grid.colorX, fx.grid.colorZ, @@ -1690,7 +1701,7 @@ export default function PlayCanvasViewer({ const app = appRef.current if (!app) return setWireframeForScene(app, fx.rendering.wireframe) - }, [fx.rendering.wireframe]) + }, [fx.rendering.wireframe,modelVersion]) useEffect(() => { const frame: any = cameraFrameRef.current @@ -1703,9 +1714,10 @@ export default function PlayCanvasViewer({ const app = appRef.current if (!app) return if (fx.lighting) { + app.scene.ambientLight = new pc.Color(fx.lighting.ambientIntensity,fx.lighting.ambientIntensity,fx.lighting.ambientIntensity) app.scene.exposure = fx.lighting.exposure } - }, [fx.lighting?.exposure]) + }, [fx.lighting?.exposure,fx.lighting.ambientIntensity]) useEffect(() => { const app = appRef.current @@ -1924,6 +1936,8 @@ export default function PlayCanvasViewer({ envAtlasVersion, skyboxVersion, modelVersion, + materials, + assignments, fx.envAtlas.enabled, fx.envAtlas.background, fx.envAtlas.reflection, diff --git a/apps/control-station/src/components/rover/playcanvas/playcanvasPostFx.ts b/apps/control-station/src/components/rover/playcanvas/playcanvasPostFx.ts index 053e745..2a6cfaa 100644 --- a/apps/control-station/src/components/rover/playcanvas/playcanvasPostFx.ts +++ b/apps/control-station/src/components/rover/playcanvas/playcanvasPostFx.ts @@ -1,393 +1,2 @@ - -export type PostFxSettings = { - lighting: { - exposure: number - skyBoxIntensity: number - } - envAtlas: { - enabled: boolean - background: boolean - reflection: boolean - intensity: number - reflectionIntensity: number - brightness: number - contrast: number - saturation: number - toneMapping: number - mip: number - rotation: number - } - skybox: { - enabled: boolean - background: boolean - reflection: boolean - intensity: number - reflectionIntensity: number - mip: number - rotation: number - colorA: string - colorB: string - } - rendering: { - backgroundColor: string - wireframe: boolean - renderFormat: number - renderFormatFallback0: number - renderFormatFallback1: number - stencil: boolean - renderTargetScale: number - samples: number - sharpness: number - toneMapping: number - sceneColorMap: boolean - sceneDepthMap: boolean - fog: 'none' | 'linear' | 'exp' | 'exp2' - fogColor: string - fogRange: [number, number] - fogDensity: number - fogStart: number - fogEnd: number - } - grid: { - enabled: boolean - colorX: string - colorZ: string - colorMain: string - alphaX: number - alphaZ: number - alphaMain: number - dotsEnabled: boolean - dotsColor: string - dotsAlpha: number - dotsDiameter: number - crossEnabled: boolean - crossColor: string - crossAlpha: number - crossLength: number - crossWidth: number - fadeStart: number - fadeEnd: number - } - ssao: { - type: 'none' | 'lighting' | 'combine' - blurEnabled: boolean - intensity: number - radius: number - samples: number - power: number - minAngle: number - scale: number - } - bloom: { - enabled: boolean - intensity: number - lastMipLevel: number - } - chromaticAberration: { - enabled: boolean - intensity: number - } - taa: { - enabled: boolean - jitter: number - } - grading: { - enabled: boolean - brightness: number - contrast: number - saturation: number - tint: string - } - lut: { - intensity: number - textureUrl?: string | null - } - vignette: { - enabled: boolean - intensity: number - inner: number - outer: number - curvature: number - color: string - } - directionalLight: { - enabled: boolean - color: string - intensity: number - azimuth: number - elevation: number - castShadows: boolean - shadowIntensity: number - shadowDistance: number - shadowResolution: number - shadowBias: number - normalOffsetBias: number - shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3' - vsmBlurSize: number - } - shadowCatcher: { - enabled: boolean - size: number - yOffset: number - lightIntensity: number - lightColor: string - lightAzimuth: number - lightElevation: number - shadowIntensity: number - shadowDistance: number - shadowResolution: number - shadowBias: number - normalOffsetBias: number - shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3' - vsmBlurSize: number - } -} - -export const DEFAULT_POSTFX: PostFxSettings = { - lighting: { - exposure: 1.21, - skyBoxIntensity: 0.86, - }, - envAtlas: { - enabled: true, - background: false, - reflection: true, - intensity: 0.76, - reflectionIntensity: 0.6, - brightness: 1.65, - contrast: 1, - saturation: 1, - toneMapping: 0, - mip: 1, - rotation: 247, - }, - skybox: { - enabled: false, - background: false, - reflection: true, - intensity: 0.62, - reflectionIntensity: 1, - mip: 0, - rotation: 0, - colorA: '#c5bfbf', - colorB: '#c7bcbc', - }, - rendering: { - backgroundColor: '#111113', - wireframe: false, - renderFormat: 18, - renderFormatFallback0: 12, - renderFormatFallback1: 14, - stencil: false, - renderTargetScale: 1, - samples: 4, - sharpness: 0, - toneMapping: 4, - sceneColorMap: false, - sceneDepthMap: false, - fog: 'exp', - fogColor: '#dcc2ff', - fogRange: [0, 100], - fogDensity: 0.008, - fogStart: 0, - fogEnd: 100, - }, - grid: { - enabled: true, - colorX: '#ffffff', - colorZ: '#ffffff', - colorMain: '#7a3cff', - alphaX: 0.18, - alphaZ: 0.18, - alphaMain: 0.6, - dotsEnabled: false, - dotsColor: '#ffffff', - dotsAlpha: 0.5, - dotsDiameter: 0.06, - crossEnabled: false, - crossColor: '#ffffff', - crossAlpha: 0.5, - crossLength: 0.5, - crossWidth: 0.06, - fadeStart: 0, - fadeEnd: 0, - }, - ssao: { - type: 'none', - blurEnabled: true, - intensity: 0.5, - radius: 30, - samples: 12, - power: 6, - minAngle: 10, - scale: 1, - }, - bloom: { - enabled: true, - intensity: 0.03, - lastMipLevel: 4, - }, - chromaticAberration: { - enabled: true, - intensity: 30, - }, - taa: { - enabled: false, - jitter: 1, - }, - grading: { - enabled: true, - brightness: 0.837, - contrast: 1.1, - saturation: 1.126, - tint: '#ffffff', - }, - lut: { - intensity: 1, - textureUrl: null, - }, - vignette: { - enabled: true, - intensity: 1, - inner: 0.25, - outer: 1.52, - curvature: 0.78, - color: '#000000', - }, - directionalLight: { - enabled: true, - color: '#ffffff', - intensity: 0.4, - azimuth: 0, - elevation: 0, - castShadows: false, - shadowIntensity: 0.5, - shadowDistance: 16, - shadowResolution: 1024, - shadowBias: 0, - normalOffsetBias: 0, - shadowType: 'vsm16', - vsmBlurSize: 8, - }, - shadowCatcher: { - enabled: true, - size: 9, - yOffset: 0.001, - lightIntensity: 0.3, - lightColor: '#ffffff', - lightAzimuth: 20, - lightElevation: 60, - shadowIntensity: 0.29, - shadowDistance: 16, - shadowResolution: 2048, - shadowBias: 0, - normalOffsetBias: 0, - shadowType: 'vsm16', - vsmBlurSize: 8, - }, -} - -function isObject(value: any): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value) -} - -export function mergePostFxDefaults(raw?: Partial | null): PostFxSettings { - if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings - - const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX)) - const merge = (target: any, source: any) => { - if (!isObject(source)) return - for (const [key, value] of Object.entries(source)) { - if (isObject(value)) { - if (!isObject(target[key])) target[key] = {} - merge(target[key], value) - } else { - target[key] = value - } - } - } - merge(out, raw) - - const legacy: any = raw as any - if (legacy?.hdrLight) { - const src = legacy.hdrLight - const env = legacy?.envAtlas ?? {} - if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled - if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox - if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity - if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip - if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation - } - if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) { - if (typeof legacy.hdrReflection.enabled === 'boolean') { - out.envAtlas.reflection = legacy.hdrReflection.enabled - } - } - if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') { - out.envAtlas.reflectionIntensity = out.envAtlas.intensity - } - if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') { - out.envAtlas.brightness = 1 - } - if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') { - out.envAtlas.contrast = 1 - } - if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') { - out.envAtlas.saturation = 1 - } - if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') { - out.envAtlas.toneMapping = 0 - } - if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') { - out.skybox.reflectionIntensity = out.skybox.intensity - } - if (out.grid && typeof out.grid.enabled !== 'boolean') { - out.grid.enabled = DEFAULT_POSTFX.grid.enabled - } - if (out.grid && typeof out.grid.colorX !== 'string') { - out.grid.colorX = DEFAULT_POSTFX.grid.colorX - } - if (out.grid && typeof out.grid.colorZ !== 'string') { - out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ - } - if (out.grid && typeof out.grid.colorMain !== 'string') { - out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain - } - if (out.grid && typeof out.grid.alphaX !== 'number') { - out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX - } - if (out.grid && typeof out.grid.alphaZ !== 'number') { - out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ - } - if (out.grid && typeof out.grid.alphaMain !== 'number') { - out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain - } - if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') { - out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled - } - if (out.grid && typeof out.grid.dotsColor !== 'string') { - out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor - } - if (out.grid && typeof out.grid.dotsAlpha !== 'number') { - out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha - } - if (out.grid && typeof out.grid.dotsDiameter !== 'number') { - out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter - } - if (out.grid && typeof out.grid.crossEnabled !== 'boolean') { - out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled - } - if (out.grid && typeof out.grid.crossColor !== 'string') { - out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor - } - if (out.grid && typeof out.grid.crossAlpha !== 'number') { - out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha - } - if (out.grid && typeof out.grid.crossLength !== 'number') { - out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength - } - if (out.grid && typeof out.grid.crossWidth !== 'number') { - out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth - } - return out as PostFxSettings -} +// Shared serializable contract; donor defaults remain available. +export * from '../../../core/roverScene/postFx'; diff --git a/apps/control-station/src/components/rover/playcanvas/useLutTexture.ts b/apps/control-station/src/components/rover/playcanvas/useLutTexture.ts new file mode 100644 index 0000000..1305060 --- /dev/null +++ b/apps/control-station/src/components/rover/playcanvas/useLutTexture.ts @@ -0,0 +1,17 @@ +import {useEffect,type RefObject} from 'react'; +import * as pc from 'playcanvas'; +export function useLutTexture(appRef:RefObject,frameRef:RefObject,textureRef:RefObject,url:string|null|undefined,ready:boolean,onError?:()=>void){ + useEffect(()=>{ + const app=appRef.current;let cancelled=false; + const clear=()=>{if(frameRef.current?.colorLUT){frameRef.current.colorLUT.texture=null;frameRef.current.update?.();}textureRef.current?.destroy();textureRef.current=null;}; + clear();if(!app||!ready||!url)return; + const img=new Image();img.onload=()=>{ + if(cancelled)return; + if(img.width!==256||img.height!==16){onError?.();return;} + const texture=new pc.Texture(app.graphicsDevice,{name:'Rover color LUT',format:pc.PIXELFORMAT_SRGBA8,mipmaps:false,minFilter:pc.FILTER_LINEAR,magFilter:pc.FILTER_LINEAR,addressU:pc.ADDRESS_CLAMP_TO_EDGE,addressV:pc.ADDRESS_CLAMP_TO_EDGE}); + texture.setSource(img);textureRef.current=texture; + if(frameRef.current?.colorLUT){frameRef.current.colorLUT.texture=texture;frameRef.current.update?.();} + };img.onerror=()=>{if(!cancelled)onError?.();};img.src=url; + return()=>{cancelled=true;clear();}; + },[appRef,frameRef,textureRef,url,ready,onError]); +} diff --git a/apps/control-station/src/components/rover/playcanvas/useSceneMaterials.ts b/apps/control-station/src/components/rover/playcanvas/useSceneMaterials.ts new file mode 100644 index 0000000..016c966 --- /dev/null +++ b/apps/control-station/src/components/rover/playcanvas/useSceneMaterials.ts @@ -0,0 +1,83 @@ +import {useEffect,useRef,type RefObject} from 'react'; +import * as pc from 'playcanvas'; +import type {MaterialSpec,TextureSlot} from '../../../core/roverScene/sceneDocument'; +export type SceneMaterialsProps={materials?:Record;assignments?:Record;editing?:boolean;onMaterialPick?:(id:string,group:string)=>void;onTextureState?:(state:'loading'|'ready'|'error')=>void}; +const maps:Record={diffuse:'diffuseMap',normal:'normalMap',metalness:'metalnessMap',roughness:'glossMap',opacity:'opacityMap',emissive:'emissiveMap',ao:'aoMap'}; +export function useSceneMaterials(appRef:RefObject,cameraRef:RefObject,canvasRef:RefObject,modelVersion:number,{materials,assignments,editing,onMaterialPick,onTextureState}:SceneMaterialsProps){ + const latest=useRef({materials,onMaterialPick,onTextureState});latest.current={materials,onMaterialPick,onTextureState}; + const textures=useRef(new Map}>()); + const generation=useRef(0); + const originalGroups=useRef(new WeakMap()); + const customMaterials=useRef(new Map()); + useEffect(()=>{ + const app=appRef.current;if(!app||!modelVersion||!materials)return; + const current=++generation.current;let pending=0,failed=false; + const map=new Map(); + for(const component of (app.root.findByName('ModelRoot') as pc.Entity|null)?.findComponents('render')??[])for(const mi of (component as pc.RenderComponent).meshInstances){ + if(!(mi.material instanceof pc.StandardMaterial))continue; + if(!originalGroups.current.has(mi))originalGroups.current.set(mi,{id:mi.material.name,material:mi.material}); + const source=originalGroups.current.get(mi)!;map.set(source.id,source.material); + } + for(const id of Object.keys(materials))if(id.startsWith('custom/')){ + if(!customMaterials.current.has(id)){const m=new pc.StandardMaterial();m.name=id;customMaterials.current.set(id,m);} + map.set(id,customMaterials.current.get(id)!); + } + for(const component of (app.root.findByName('ModelRoot') as pc.Entity|null)?.findComponents('render')??[])for(const mi of (component as pc.RenderComponent).meshInstances){ + const source=originalGroups.current.get(mi);if(source)mi.material=map.get(assignments?.[source.id]??source.id)??source.material; + } + for(const [id,m] of customMaterials.current)if(!Object.hasOwn(materials,id)){m.destroy();customMaterials.current.delete(id);} + const applyTexture=(m:pc.StandardMaterial,slot:TextureSlot,texture:pc.Texture|null,spec:MaterialSpec)=>{ + (m as any)[maps[slot]]=texture; + const prefix=maps[slot];(m as any)[prefix+'Tiling']=new pc.Vec2(spec.tiling,spec.tiling); + if(['metalness','roughness','opacity','ao'].includes(slot))(m as any)[prefix+'Channel']='r'; + m.update(); + }; + const used=new Set(); + for(const [id,spec] of Object.entries(materials)){ + const m=map.get(id);if(!m)continue; + m.diffuse.fromString(spec.color);m.useMetalness=true;m.metalness=spec.metalness; + m.glossInvert=true;m.gloss=spec.roughness;m.opacity=spec.opacity; + // Specular factor is independent from the donor's global IBL multiplier. + m.specularityFactor=spec.reflectivity;m.clearCoat=spec.clearCoat; + m.clearCoatGlossInvert=true;m.clearCoatGloss=spec.clearCoatRoughness; + m.emissive.fromString(spec.emissive);m.emissiveIntensity=spec.emissiveIntensity; + m.bumpiness=spec.normalStrength;m.cull=spec.doubleSided?pc.CULLFACE_NONE:pc.CULLFACE_BACK; + m.blendType=spec.opacity<1||spec.textures.opacity?pc.BLEND_NORMAL:pc.BLEND_NONE; + m.depthWrite=m.blendType===pc.BLEND_NONE; + for(const slot of Object.keys(maps) as TextureSlot[]){ + const data=spec.textures[slot]?.data;if(!data){applyTexture(m,slot,null,spec);continue;} + const srgb=slot==='diffuse'||slot==='emissive';const key=`${srgb}:${data}`;used.add(key); + let item=textures.current.get(key); + if(!item){ + const promise=new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>{if(generation.current<0){reject(new Error('Scene disposed'));return;}const tex=new pc.Texture(app.graphicsDevice,{name:spec.textures[slot]?.name,format:srgb?pc.PIXELFORMAT_SRGBA8:pc.PIXELFORMAT_RGBA8,mipmaps:true,addressU:pc.ADDRESS_REPEAT,addressV:pc.ADDRESS_REPEAT});tex.setSource(img);resolve(tex);};img.onerror=()=>reject(new Error('Texture decode failed'));img.src=data;}); + item={promise};textures.current.set(key,item);const cached=item; + promise.then(tex=>{cached.texture=tex;if(textures.current.get(key)!==cached){tex.destroy();}}).catch(()=>{if(textures.current.get(key)===cached)textures.current.delete(key);}); + } + if(item.texture)applyTexture(m,slot,item.texture,spec); + else{ + pending++;item.promise.then(tex=>{if(generation.current===current)applyTexture(m,slot,tex,spec);}).catch(()=>{failed=true;}).finally(()=>{pending--;if(generation.current===current&&!pending)latest.current.onTextureState?.(failed?'error':'ready');}); + } + } + m.update(); + } + for(const [key,item] of textures.current)if(!used.has(key)){item.texture?.destroy();textures.current.delete(key);} + onTextureState?.(pending?'loading':'ready'); + return()=>{generation.current++;}; + },[materials,assignments,modelVersion,appRef,onTextureState]); + useEffect(()=>()=>{generation.current=-1;for(const item of textures.current.values())item.texture?.destroy();textures.current.clear();for(const m of customMaterials.current.values())m.destroy();customMaterials.current.clear();},[]); + useEffect(()=>{ + const app=appRef.current,canvas=canvasRef.current,camera=cameraRef.current; + if(!editing||!app||!canvas||!camera?.camera||!modelVersion)return; + const picker=new pc.Picker(app,1,1);let start:{x:number;y:number}|null=null; + const down=(e:PointerEvent)=>{start=e.button===0?{x:e.clientX,y:e.clientY}:null;}; + const up=(e:PointerEvent)=>{ + const p=start;start=null;if(!p||Math.hypot(p.x-e.clientX,p.y-e.clientY)>4)return; + const rect=canvas.getBoundingClientRect();picker.resize(Math.max(1,Math.floor(rect.width)),Math.max(1,Math.floor(rect.height))); + picker.prepare(camera.camera!,app.scene); + const hit=picker.getSelection(e.clientX-rect.left,e.clientY-rect.top).find(mi=>!!latest.current.materials?.[mi.material.name]); + if(hit instanceof pc.MeshInstance){const source=originalGroups.current.get(hit);if(source)latest.current.onMaterialPick?.(hit.material.name,source.id);} + }; + canvas.addEventListener('pointerdown',down);canvas.addEventListener('pointerup',up); + return()=>{canvas.removeEventListener('pointerdown',down);canvas.removeEventListener('pointerup',up);picker.destroy();}; + },[editing,modelVersion,appRef,cameraRef,canvasRef]); +} diff --git a/apps/control-station/src/components/rover/rover.css b/apps/control-station/src/components/rover/rover.css index a8d3263..49d7223 100644 --- a/apps/control-station/src/components/rover/rover.css +++ b/apps/control-station/src/components/rover/rover.css @@ -2,7 +2,7 @@ .rover-view:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; } .rover-view .rover-view__scene { position:absolute; inset:0; } .rover-view__empty { display:grid; place-content:center; height:100%; text-align:center; font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-muted); } -.rover-view__status { position:absolute; top:var(--nodedc-space-3); left:var(--nodedc-space-3); } +.rover-view__status { position:absolute; top:calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3)); left:var(--nodedc-space-3); } .rover-view__controls { position:absolute; right:var(--nodedc-space-3); bottom:var(--nodedc-space-3); display:flex; flex-direction:column; gap:var(--nodedc-space-2); align-items:center; } .rover-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); } .rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); } @@ -10,7 +10,7 @@ .rover-key-KeyQ { grid-column:1; }.rover-key-KeyE { grid-column:3; } .rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); } @container (max-width: 380px) { - .rover-view__authority { top:48px; bottom:auto; } + .rover-view:has(.rover-view__controls) .rover-view__authority { top:calc(var(--observation-overlay-header-space, 0px) + 48px); bottom:auto; } } .rover-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-3); } .rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); } diff --git a/apps/control-station/src/core/roverScene/environmentFields.json b/apps/control-station/src/core/roverScene/environmentFields.json new file mode 100644 index 0000000..b063096 --- /dev/null +++ b/apps/control-station/src/core/roverScene/environmentFields.json @@ -0,0 +1,1185 @@ +[ + { + "key": "cameraZoomStrength", + "type": "sliderNumber", + "label": "Сила зума", + "group": "Камера", + "default": 0.04, + "min": 0.005, + "max": 0.2, + "step": 0.005 + }, + { + "key": "cameraAutoRotate", + "type": "toggle", + "label": "Автовращение", + "group": "Камера", + "default": true + }, + { + "key": "cameraResetTs", + "type": "action", + "label": "Возврат камеры в дефолтный режим", + "group": "Камера", + "action": "reset-camera", + "layout": "split", + "buttonLabel": "Сбросить камеру" + }, + { + "key": "postFx.lighting.exposure", + "type": "sliderNumber", + "label": "Exposure", + "group": "PostProcess/Lighting", + "default": 1.21, + "min": 0.1, + "max": 2, + "step": 0.01 + }, + { + "key": "postFx.lighting.skyBoxIntensity", + "type": "sliderNumber", + "label": "IBL intensity", + "group": "PostProcess/Lighting", + "default": 0.86, + "min": 0.1, + "max": 2, + "step": 0.01 + }, + { + "key": "postFx.rendering.backgroundColor", + "type": "color", + "label": "Background", + "group": "PostProcess/Rendering", + "default": "#111113" + }, + { + "key": "postFx.rendering.wireframe", + "type": "toggle", + "label": "Wireframe", + "group": "PostProcess/Rendering", + "default": false + }, + { + "key": "postFx.rendering.renderFormat", + "type": "select", + "label": "Render format", + "group": "PostProcess/Rendering", + "default": 18, + "options": [ + { + "label": "RGBA8", + "value": "7" + }, + { + "label": "RG11B10", + "value": "18" + }, + { + "label": "RGBA16", + "value": "12" + }, + { + "label": "RGBA32", + "value": "14" + } + ] + }, + { + "key": "postFx.rendering.renderTargetScale", + "type": "sliderNumber", + "label": "Render target scale", + "group": "PostProcess/Rendering", + "default": 1, + "min": 0.1, + "max": 1, + "step": 0.01 + }, + { + "key": "postFx.rendering.samples", + "type": "sliderNumber", + "label": "Samples", + "group": "PostProcess/Rendering", + "default": 4, + "min": 1, + "max": 4, + "step": 1 + }, + { + "key": "postFx.rendering.sharpness", + "type": "sliderNumber", + "label": "Sharpness", + "group": "PostProcess/Rendering", + "default": 0, + "min": 0, + "max": 1, + "step": 0.001 + }, + { + "key": "postFx.rendering.toneMapping", + "type": "select", + "label": "Tone mapping", + "group": "PostProcess/Rendering", + "default": 4, + "options": [ + { + "label": "Linear", + "value": "0" + }, + { + "label": "Filmic", + "value": "1" + }, + { + "label": "Hejl", + "value": "2" + }, + { + "label": "ACES", + "value": "3" + }, + { + "label": "ACES 2", + "value": "4" + }, + { + "label": "Neutral", + "value": "5" + } + ] + }, + { + "key": "postFx.rendering.fog", + "type": "select", + "label": "Fog", + "group": "PostProcess/Rendering", + "default": "exp", + "options": [ + { + "label": "None", + "value": "none" + }, + { + "label": "Linear", + "value": "linear" + }, + { + "label": "Exp", + "value": "exp" + }, + { + "label": "Exp2", + "value": "exp2" + } + ] + }, + { + "key": "postFx.rendering.fogColor", + "type": "color", + "label": "Fog color", + "group": "PostProcess/Rendering", + "default": "#dcc2ff", + "disabledIf": "fogDisabled" + }, + { + "key": "postFx.rendering.fogRange", + "type": "doubleSlider", + "label": "Fog range", + "group": "PostProcess/Rendering", + "default": [ + 0, + 100 + ], + "min": 0, + "max": 100, + "step": 1, + "disabledIf": "fogDisabled" + }, + { + "key": "postFx.rendering.fogDensity", + "type": "sliderNumber", + "label": "Fog density", + "group": "PostProcess/Rendering", + "default": 0.008, + "min": 0, + "max": 1, + "step": 0.001, + "disabledIf": "fogDisabled" + }, + { + "key": "postFx.rendering.sceneColorMap", + "type": "toggle", + "label": "Scene color map", + "group": "PostProcess/Rendering", + "default": false + }, + { + "key": "postFx.rendering.sceneDepthMap", + "type": "toggle", + "label": "Scene depth map", + "group": "PostProcess/Rendering", + "default": false + }, + { + "key": "postFx.grid.enabled", + "type": "toggle", + "label": "Сетка: включить", + "group": "PostProcess/Grid", + "default": true + }, + { + "key": "postFx.grid.colorX", + "type": "color", + "label": "Ось X: цвет", + "group": "PostProcess/Grid", + "default": "#ffffff", + "disabledIf": "!postFx.grid.enabled" + }, + { + "key": "postFx.grid.alphaX", + "type": "sliderNumber", + "label": "Ось X: прозрачность", + "group": "PostProcess/Grid", + "default": 0.18, + "min": 0, + "max": 1, + "step": 0.01, + "disabledIf": "!postFx.grid.enabled" + }, + { + "key": "postFx.grid.colorZ", + "type": "color", + "label": "Ось Z: цвет", + "group": "PostProcess/Grid", + "default": "#ffffff", + "disabledIf": "!postFx.grid.enabled" + }, + { + "key": "postFx.grid.alphaZ", + "type": "sliderNumber", + "label": "Ось Z: прозрачность", + "group": "PostProcess/Grid", + "default": 0.18, + "min": 0, + "max": 1, + "step": 0.01, + "disabledIf": "!postFx.grid.enabled" + }, + { + "key": "postFx.grid.colorMain", + "type": "color", + "label": "Сетка: цвет", + "group": "PostProcess/Grid", + "default": "#7a3cff", + "disabledIf": "!postFx.grid.enabled" + }, + { + "key": "postFx.grid.alphaMain", + "type": "sliderNumber", + "label": "Сетка: прозрачность", + "group": "PostProcess/Grid", + "default": 0.6, + "min": 0, + "max": 1, + "step": 0.01, + "disabledIf": "!postFx.grid.enabled" + }, + { + "key": "postFx.grid.dotsEnabled", + "type": "toggle", + "label": "Кружки: включить", + "group": "PostProcess/Grid", + "default": false + }, + { + "key": "postFx.grid.dotsColor", + "type": "color", + "label": "Кружки: цвет", + "group": "PostProcess/Grid", + "default": "#ffffff", + "disabledIf": "gridDotsDisabled" + }, + { + "key": "postFx.grid.dotsAlpha", + "type": "sliderNumber", + "label": "Кружки: прозрачность", + "group": "PostProcess/Grid", + "default": 0.5, + "min": 0, + "max": 1, + "step": 0.01, + "disabledIf": "gridDotsDisabled" + }, + { + "key": "postFx.grid.dotsDiameter", + "type": "number", + "label": "Кружки: диаметр", + "group": "PostProcess/Grid", + "default": 0.06, + "min": 0, + "step": 0.001, + "disabledIf": "gridDotsDisabled" + }, + { + "key": "postFx.grid.crossEnabled", + "type": "toggle", + "label": "Кресты: включить", + "group": "PostProcess/Grid", + "default": false + }, + { + "key": "postFx.grid.crossColor", + "type": "color", + "label": "Кресты: цвет", + "group": "PostProcess/Grid", + "default": "#ffffff", + "disabledIf": "gridCrossDisabled" + }, + { + "key": "postFx.grid.crossAlpha", + "type": "sliderNumber", + "label": "Кресты: прозрачность", + "group": "PostProcess/Grid", + "default": 0.5, + "min": 0, + "max": 1, + "step": 0.01, + "disabledIf": "gridCrossDisabled" + }, + { + "key": "postFx.grid.crossLength", + "type": "number", + "label": "Кресты: длина", + "group": "PostProcess/Grid", + "default": 0.5, + "min": 0, + "step": 0.001, + "disabledIf": "gridCrossDisabled" + }, + { + "key": "postFx.grid.crossWidth", + "type": "number", + "label": "Кресты: ширина", + "group": "PostProcess/Grid", + "default": 0.06, + "min": 0, + "step": 0.001, + "disabledIf": "gridCrossDisabled" + }, + { + "key": "postFx.grid.fadeStart", + "type": "number", + "label": "Затухание: начало (м)", + "group": "PostProcess/Grid", + "default": 0, + "min": 0, + "step": 1 + }, + { + "key": "postFx.grid.fadeEnd", + "type": "number", + "label": "Затухание: конец (м)", + "group": "PostProcess/Grid", + "default": 0, + "min": 0, + "step": 1 + }, + { + "key": "postFx.ssao.type", + "type": "select", + "label": "Type", + "group": "PostProcess/SSAO", + "default": "none", + "options": [ + { + "label": "Off", + "value": "none" + }, + { + "label": "Lighting", + "value": "lighting" + }, + { + "label": "Combine", + "value": "combine" + } + ] + }, + { + "key": "postFx.ssao.intensity", + "type": "sliderNumber", + "label": "Intensity", + "group": "PostProcess/SSAO", + "default": 0.5, + "min": 0, + "max": 1, + "step": 0.001, + "disabledIf": "ssaoDisabled" + }, + { + "key": "postFx.ssao.radius", + "type": "sliderNumber", + "label": "Radius", + "group": "PostProcess/SSAO", + "default": 30, + "min": 0, + "max": 100, + "step": 0.001, + "disabledIf": "ssaoDisabled" + }, + { + "key": "postFx.ssao.samples", + "type": "sliderNumber", + "label": "Samples", + "group": "PostProcess/SSAO", + "default": 12, + "min": 1, + "max": 64, + "step": 1, + "disabledIf": "ssaoDisabled" + }, + { + "key": "postFx.ssao.power", + "type": "sliderNumber", + "label": "Power", + "group": "PostProcess/SSAO", + "default": 6, + "min": 0.1, + "max": 10, + "step": 0.001, + "disabledIf": "ssaoDisabled" + }, + { + "key": "postFx.ssao.minAngle", + "type": "sliderNumber", + "label": "Min angle", + "group": "PostProcess/SSAO", + "default": 10, + "min": 1, + "max": 90, + "step": 1, + "disabledIf": "ssaoDisabled" + }, + { + "key": "postFx.ssao.scale", + "type": "sliderNumber", + "label": "Scale", + "group": "PostProcess/SSAO", + "default": 1, + "min": 0.5, + "max": 1, + "step": 0.001, + "disabledIf": "ssaoDisabled" + }, + { + "key": "postFx.bloom.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/Bloom", + "default": true + }, + { + "key": "postFx.bloom.intensity", + "type": "sliderNumber", + "label": "Intensity", + "group": "PostProcess/Bloom", + "default": 0.03, + "min": 0, + "max": 1, + "step": 0.001, + "disabledIf": "!postFx.bloom.enabled" + }, + { + "key": "postFx.bloom.lastMipLevel", + "type": "sliderNumber", + "label": "Last mip level", + "group": "PostProcess/Bloom", + "default": 4, + "min": 0, + "max": 12, + "step": 1, + "disabledIf": "!postFx.bloom.enabled" + }, + { + "key": "postFx.chromaticAberration.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/Chromatic Aberration", + "default": true + }, + { + "key": "postFx.chromaticAberration.intensity", + "type": "sliderNumber", + "label": "Intensity", + "group": "PostProcess/Chromatic Aberration", + "default": 30, + "min": 0, + "max": 100, + "step": 0.01, + "disabledIf": "!postFx.chromaticAberration.enabled" + }, + { + "key": "postFx.taa.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/TAA", + "default": false + }, + { + "key": "postFx.taa.jitter", + "type": "sliderNumber", + "label": "Jitter", + "group": "PostProcess/TAA", + "default": 1, + "min": 0, + "max": 1, + "step": 0.1, + "disabledIf": "!postFx.taa.enabled" + }, + { + "key": "postFx.grading.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/Grading", + "default": true + }, + { + "key": "postFx.grading.brightness", + "type": "sliderNumber", + "label": "Brightness", + "group": "PostProcess/Grading", + "default": 0.837, + "min": 0, + "max": 3, + "step": 0.001, + "disabledIf": "!postFx.grading.enabled" + }, + { + "key": "postFx.grading.contrast", + "type": "sliderNumber", + "label": "Contrast", + "group": "PostProcess/Grading", + "default": 1.1, + "min": 0.5, + "max": 1.5, + "step": 0.001, + "disabledIf": "!postFx.grading.enabled" + }, + { + "key": "postFx.grading.saturation", + "type": "sliderNumber", + "label": "Saturation", + "group": "PostProcess/Grading", + "default": 1.126, + "min": 0, + "max": 2, + "step": 0.001, + "disabledIf": "!postFx.grading.enabled" + }, + { + "key": "postFx.grading.tint", + "type": "color", + "label": "Tint", + "group": "PostProcess/Grading", + "default": "#ffffff", + "disabledIf": "!postFx.grading.enabled" + }, + { + "key": "postFx.lut.intensity", + "type": "sliderNumber", + "label": "LUT intensity", + "group": "PostProcess/LUT", + "default": 1, + "min": 0, + "max": 1, + "step": 0.001, + "disabledIf": "!postFx.grading.enabled" + }, + { + "key": "postFx.vignette.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/Vignette", + "default": true + }, + { + "key": "postFx.vignette.intensity", + "type": "sliderNumber", + "label": "Intensity", + "group": "PostProcess/Vignette", + "default": 1, + "min": 0, + "max": 1, + "step": 0.001, + "disabledIf": "!postFx.vignette.enabled" + }, + { + "key": "postFx.vignette.inner", + "type": "sliderNumber", + "label": "Inner", + "group": "PostProcess/Vignette", + "default": 0.25, + "min": 0, + "max": 3, + "step": 0.001, + "disabledIf": "!postFx.vignette.enabled" + }, + { + "key": "postFx.vignette.outer", + "type": "sliderNumber", + "label": "Outer", + "group": "PostProcess/Vignette", + "default": 1.52, + "min": 0, + "max": 3, + "step": 0.001, + "disabledIf": "!postFx.vignette.enabled" + }, + { + "key": "postFx.vignette.curvature", + "type": "sliderNumber", + "label": "Curvature", + "group": "PostProcess/Vignette", + "default": 0.78, + "min": 0.01, + "max": 3, + "step": 0.001, + "disabledIf": "!postFx.vignette.enabled" + }, + { + "key": "postFx.vignette.color", + "type": "color", + "label": "Color", + "group": "PostProcess/Vignette", + "default": "#000000", + "disabledIf": "!postFx.vignette.enabled" + }, + { + "key": "postFx.envAtlas.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/Environment Atlas", + "default": true, + "disabledIf": "envAtlasBlocked" + }, + { + "key": "postFx.envAtlas.background", + "type": "toggle", + "label": "Background", + "group": "PostProcess/Environment Atlas", + "default": false, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.reflection", + "type": "toggle", + "label": "Reflection", + "group": "PostProcess/Environment Atlas", + "default": true, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.intensity", + "type": "sliderNumber", + "label": "Intensity (Background)", + "group": "PostProcess/Environment Atlas", + "default": 0.76, + "min": 0, + "max": 3, + "step": 0.01, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.reflectionIntensity", + "type": "sliderNumber", + "label": "Intensity (Reflection)", + "group": "PostProcess/Environment Atlas", + "default": 0.6, + "min": 0, + "max": 3, + "step": 0.01, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.toneMapping", + "type": "select", + "label": "ToneMap (Global)", + "group": "PostProcess/Environment Atlas", + "default": 0, + "options": [ + { + "label": "Linear", + "value": "0" + }, + { + "label": "Filmic", + "value": "1" + }, + { + "label": "Hejl", + "value": "2" + }, + { + "label": "ACES", + "value": "3" + }, + { + "label": "ACES 2", + "value": "4" + }, + { + "label": "Neutral", + "value": "5" + } + ], + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.brightness", + "type": "sliderNumber", + "label": "Brightness (Global)", + "group": "PostProcess/Environment Atlas", + "default": 1.65, + "min": 0, + "max": 3, + "step": 0.01, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.contrast", + "type": "sliderNumber", + "label": "Contrast (Global)", + "group": "PostProcess/Environment Atlas", + "default": 1, + "min": 0.5, + "max": 1.5, + "step": 0.01, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.saturation", + "type": "sliderNumber", + "label": "Saturation (Global)", + "group": "PostProcess/Environment Atlas", + "default": 1, + "min": 0, + "max": 2, + "step": 0.01, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.mip", + "type": "sliderNumber", + "label": "Mip", + "group": "PostProcess/Environment Atlas", + "default": 1, + "min": 0, + "max": 7, + "step": 1, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.envAtlas.rotation", + "type": "sliderNumber", + "label": "Rotation", + "group": "PostProcess/Environment Atlas", + "default": 247, + "min": 0, + "max": 360, + "step": 1, + "disabledIf": "envAtlasFieldsDisabled" + }, + { + "key": "postFx.skybox.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/Skybox", + "default": false, + "disabledIf": "skyboxBlocked" + }, + { + "key": "postFx.skybox.background", + "type": "toggle", + "label": "Background", + "group": "PostProcess/Skybox", + "default": false, + "disabledIf": "skyboxFieldsDisabled" + }, + { + "key": "postFx.skybox.reflection", + "type": "toggle", + "label": "Reflection", + "group": "PostProcess/Skybox", + "default": true, + "disabledIf": "skyboxFieldsDisabled" + }, + { + "key": "postFx.skybox.intensity", + "type": "sliderNumber", + "label": "Intensity (Background)", + "group": "PostProcess/Skybox", + "default": 0.62, + "min": 0, + "max": 3, + "step": 0.01, + "disabledIf": "skyboxFieldsDisabled" + }, + { + "key": "postFx.skybox.reflectionIntensity", + "type": "sliderNumber", + "label": "Intensity (Reflection)", + "group": "PostProcess/Skybox", + "default": 1, + "min": 0, + "max": 3, + "step": 0.01, + "disabledIf": "skyboxFieldsDisabled" + }, + { + "key": "postFx.skybox.mip", + "type": "sliderNumber", + "label": "Mip", + "group": "PostProcess/Skybox", + "default": 0, + "min": 0, + "max": 7, + "step": 1, + "disabledIf": "skyboxFieldsDisabled" + }, + { + "key": "postFx.skybox.rotation", + "type": "sliderNumber", + "label": "Rotation", + "group": "PostProcess/Skybox", + "default": 0, + "min": 0, + "max": 360, + "step": 1, + "disabledIf": "skyboxFieldsDisabled" + }, + { + "key": "postFx.skybox.colorA", + "type": "color", + "label": "Color A", + "group": "PostProcess/Skybox", + "default": "#c5bfbf", + "disabledIf": "skyboxFieldsDisabled" + }, + { + "key": "postFx.skybox.colorB", + "type": "color", + "label": "Color B", + "group": "PostProcess/Skybox", + "default": "#c7bcbc", + "disabledIf": "skyboxFieldsDisabled" + }, + { + "key": "postFx.directionalLight.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/Directional Light", + "default": true + }, + { + "key": "postFx.directionalLight.color", + "type": "color", + "label": "Color", + "group": "PostProcess/Directional Light", + "default": "#ffffff", + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.intensity", + "type": "sliderNumber", + "label": "Intensity", + "group": "PostProcess/Directional Light", + "default": 0.4, + "min": 0, + "max": 5, + "step": 0.01, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.azimuth", + "type": "sliderNumber", + "label": "Azimuth", + "group": "PostProcess/Directional Light", + "default": 0, + "min": 0, + "max": 360, + "step": 1, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.elevation", + "type": "sliderNumber", + "label": "Elevation", + "group": "PostProcess/Directional Light", + "default": 0, + "min": -90, + "max": 90, + "step": 1, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.castShadows", + "type": "toggle", + "label": "Cast shadows", + "group": "PostProcess/Directional Light", + "default": false, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.shadowIntensity", + "type": "sliderNumber", + "label": "Shadow intensity", + "group": "PostProcess/Directional Light", + "default": 0.5, + "min": 0, + "max": 1, + "step": 0.01, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.shadowDistance", + "type": "sliderNumber", + "label": "Shadow distance", + "group": "PostProcess/Directional Light", + "default": 16, + "min": 1, + "max": 200, + "step": 1, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.shadowResolution", + "type": "sliderNumber", + "label": "Shadow resolution", + "group": "PostProcess/Directional Light", + "default": 1024, + "min": 256, + "max": 4096, + "step": 256, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.shadowBias", + "type": "sliderNumber", + "label": "Shadow bias", + "group": "PostProcess/Directional Light", + "default": 0, + "min": -1, + "max": 1, + "step": 0.0001, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.normalOffsetBias", + "type": "sliderNumber", + "label": "Normal offset bias", + "group": "PostProcess/Directional Light", + "default": 0, + "min": 0, + "max": 1, + "step": 0.0001, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.shadowType", + "type": "select", + "label": "Shadow type", + "group": "PostProcess/Directional Light", + "default": "vsm16", + "options": [ + { + "label": "VSM 16F", + "value": "vsm16" + }, + { + "label": "VSM 32F", + "value": "vsm32" + }, + { + "label": "PCF 1", + "value": "pcf1" + }, + { + "label": "PCF 3", + "value": "pcf3" + } + ], + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.directionalLight.vsmBlurSize", + "type": "sliderNumber", + "label": "VSM blur size", + "group": "PostProcess/Directional Light", + "default": 8, + "min": 0, + "max": 32, + "step": 1, + "disabledIf": "!postFx.directionalLight.enabled" + }, + { + "key": "postFx.shadowCatcher.enabled", + "type": "toggle", + "label": "Enabled", + "group": "PostProcess/Shadow Catcher", + "default": true + }, + { + "key": "postFx.shadowCatcher.size", + "type": "sliderNumber", + "label": "Size", + "group": "PostProcess/Shadow Catcher", + "default": 9, + "min": 0.5, + "max": 20, + "step": 0.1, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.yOffset", + "type": "sliderNumber", + "label": "Y offset", + "group": "PostProcess/Shadow Catcher", + "default": 0.001, + "min": -1, + "max": 1, + "step": 0.0001, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.lightIntensity", + "type": "sliderNumber", + "label": "Light intensity", + "group": "PostProcess/Shadow Catcher", + "default": 0.3, + "min": 0, + "max": 5, + "step": 0.01, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.lightColor", + "type": "color", + "label": "Light color", + "group": "PostProcess/Shadow Catcher", + "default": "#ffffff", + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.lightAzimuth", + "type": "sliderNumber", + "label": "Light azimuth", + "group": "PostProcess/Shadow Catcher", + "default": 20, + "min": 0, + "max": 360, + "step": 1, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.lightElevation", + "type": "sliderNumber", + "label": "Light elevation", + "group": "PostProcess/Shadow Catcher", + "default": 60, + "min": -90, + "max": 90, + "step": 1, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.shadowIntensity", + "type": "sliderNumber", + "label": "Shadow intensity", + "group": "PostProcess/Shadow Catcher", + "default": 0.29, + "min": 0, + "max": 1, + "step": 0.01, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.shadowDistance", + "type": "sliderNumber", + "label": "Shadow distance", + "group": "PostProcess/Shadow Catcher", + "default": 16, + "min": 1, + "max": 200, + "step": 1, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.shadowResolution", + "type": "sliderNumber", + "label": "Shadow resolution", + "group": "PostProcess/Shadow Catcher", + "default": 2048, + "min": 256, + "max": 4096, + "step": 256, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.shadowBias", + "type": "sliderNumber", + "label": "Shadow bias", + "group": "PostProcess/Shadow Catcher", + "default": 0, + "min": -1, + "max": 1, + "step": 0.0001, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.normalOffsetBias", + "type": "sliderNumber", + "label": "Normal offset bias", + "group": "PostProcess/Shadow Catcher", + "default": 0, + "min": 0, + "max": 1, + "step": 0.0001, + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.shadowType", + "type": "select", + "label": "Shadow type", + "group": "PostProcess/Shadow Catcher", + "default": "vsm16", + "options": [ + { + "label": "VSM 16F", + "value": "vsm16" + }, + { + "label": "VSM 32F", + "value": "vsm32" + }, + { + "label": "PCF 1", + "value": "pcf1" + }, + { + "label": "PCF 3", + "value": "pcf3" + } + ], + "disabledIf": "!postFx.shadowCatcher.enabled" + }, + { + "key": "postFx.shadowCatcher.vsmBlurSize", + "type": "sliderNumber", + "label": "VSM blur size", + "group": "PostProcess/Shadow Catcher", + "default": 8, + "min": 0, + "max": 32, + "step": 1, + "disabledIf": "!postFx.shadowCatcher.enabled" + } +] diff --git a/apps/control-station/src/core/roverScene/postFx.ts b/apps/control-station/src/core/roverScene/postFx.ts new file mode 100644 index 0000000..01e5156 --- /dev/null +++ b/apps/control-station/src/core/roverScene/postFx.ts @@ -0,0 +1,399 @@ + +export type PostFxSettings = { + lighting: { + ambientIntensity: number + exposure: number + skyBoxIntensity: number + } + envAtlas: { + enabled: boolean + background: boolean + reflection: boolean + intensity: number + reflectionIntensity: number + brightness: number + contrast: number + saturation: number + toneMapping: number + mip: number + rotation: number + } + skybox: { + enabled: boolean + background: boolean + reflection: boolean + intensity: number + reflectionIntensity: number + mip: number + rotation: number + colorA: string + colorB: string + } + rendering: { + backgroundColor: string + wireframe: boolean + renderFormat: number + renderFormatFallback0: number + renderFormatFallback1: number + stencil: boolean + renderTargetScale: number + samples: number + sharpness: number + toneMapping: number + sceneColorMap: boolean + sceneDepthMap: boolean + fog: 'none' | 'linear' | 'exp' | 'exp2' + fogColor: string + fogRange: [number, number] + fogDensity: number + fogStart: number + fogEnd: number + } + grid: { + step: number + diameter: number + enabled: boolean + colorX: string + colorZ: string + colorMain: string + alphaX: number + alphaZ: number + alphaMain: number + dotsEnabled: boolean + dotsColor: string + dotsAlpha: number + dotsDiameter: number + crossEnabled: boolean + crossColor: string + crossAlpha: number + crossLength: number + crossWidth: number + fadeStart: number + fadeEnd: number + } + ssao: { + type: 'none' | 'lighting' | 'combine' + blurEnabled: boolean + intensity: number + radius: number + samples: number + power: number + minAngle: number + scale: number + } + bloom: { + enabled: boolean + intensity: number + lastMipLevel: number + } + chromaticAberration: { + enabled: boolean + intensity: number + } + taa: { + enabled: boolean + jitter: number + } + grading: { + enabled: boolean + brightness: number + contrast: number + saturation: number + tint: string + } + lut: { + intensity: number + textureUrl?: string | null + } + vignette: { + enabled: boolean + intensity: number + inner: number + outer: number + curvature: number + color: string + } + directionalLight: { + enabled: boolean + color: string + intensity: number + azimuth: number + elevation: number + castShadows: boolean + shadowIntensity: number + shadowDistance: number + shadowResolution: number + shadowBias: number + normalOffsetBias: number + shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3' + vsmBlurSize: number + } + shadowCatcher: { + enabled: boolean + size: number + yOffset: number + lightIntensity: number + lightColor: string + lightAzimuth: number + lightElevation: number + shadowIntensity: number + shadowDistance: number + shadowResolution: number + shadowBias: number + normalOffsetBias: number + shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3' + vsmBlurSize: number + } +} + +export const DEFAULT_POSTFX: PostFxSettings = { + lighting: { + ambientIntensity: 0, + exposure: 1.21, + skyBoxIntensity: 0.86, + }, + envAtlas: { + enabled: true, + background: false, + reflection: true, + intensity: 0.76, + reflectionIntensity: 0.6, + brightness: 1.65, + contrast: 1, + saturation: 1, + toneMapping: 0, + mip: 1, + rotation: 247, + }, + skybox: { + enabled: false, + background: false, + reflection: true, + intensity: 0.62, + reflectionIntensity: 1, + mip: 0, + rotation: 0, + colorA: '#c5bfbf', + colorB: '#c7bcbc', + }, + rendering: { + backgroundColor: '#111113', + wireframe: false, + renderFormat: 18, + renderFormatFallback0: 12, + renderFormatFallback1: 14, + stencil: false, + renderTargetScale: 1, + samples: 4, + sharpness: 0, + toneMapping: 4, + sceneColorMap: false, + sceneDepthMap: false, + fog: 'exp', + fogColor: '#dcc2ff', + fogRange: [0, 100], + fogDensity: 0.008, + fogStart: 0, + fogEnd: 100, + }, + grid: { + step: 1, + diameter: 1000, + enabled: true, + colorX: '#ffffff', + colorZ: '#ffffff', + colorMain: '#7a3cff', + alphaX: 0.18, + alphaZ: 0.18, + alphaMain: 0.6, + dotsEnabled: false, + dotsColor: '#ffffff', + dotsAlpha: 0.5, + dotsDiameter: 0.06, + crossEnabled: false, + crossColor: '#ffffff', + crossAlpha: 0.5, + crossLength: 0.5, + crossWidth: 0.06, + fadeStart: 0, + fadeEnd: 0, + }, + ssao: { + type: 'none', + blurEnabled: true, + intensity: 0.5, + radius: 30, + samples: 12, + power: 6, + minAngle: 10, + scale: 1, + }, + bloom: { + enabled: true, + intensity: 0.03, + lastMipLevel: 4, + }, + chromaticAberration: { + enabled: true, + intensity: 30, + }, + taa: { + enabled: false, + jitter: 1, + }, + grading: { + enabled: true, + brightness: 0.837, + contrast: 1.1, + saturation: 1.126, + tint: '#ffffff', + }, + lut: { + intensity: 1, + textureUrl: null, + }, + vignette: { + enabled: true, + intensity: 1, + inner: 0.25, + outer: 1.52, + curvature: 0.78, + color: '#000000', + }, + directionalLight: { + enabled: true, + color: '#ffffff', + intensity: 0.4, + azimuth: 0, + elevation: 0, + castShadows: false, + shadowIntensity: 0.5, + shadowDistance: 16, + shadowResolution: 1024, + shadowBias: 0, + normalOffsetBias: 0, + shadowType: 'vsm16', + vsmBlurSize: 8, + }, + shadowCatcher: { + enabled: true, + size: 9, + yOffset: 0.001, + lightIntensity: 0.3, + lightColor: '#ffffff', + lightAzimuth: 20, + lightElevation: 60, + shadowIntensity: 0.29, + shadowDistance: 16, + shadowResolution: 2048, + shadowBias: 0, + normalOffsetBias: 0, + shadowType: 'vsm16', + vsmBlurSize: 8, + }, +} + +function isObject(value: any): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +export function mergePostFxDefaults(raw?: Partial | null): PostFxSettings { + if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings + + const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX)) + const merge = (target: any, source: any) => { + if (!isObject(source)) return + for (const [key, value] of Object.entries(source)) { + if (isObject(value)) { + if (!isObject(target[key])) target[key] = {} + merge(target[key], value) + } else { + target[key] = value + } + } + } + merge(out, raw) + + const legacy: any = raw as any + if (legacy?.hdrLight) { + const src = legacy.hdrLight + const env = legacy?.envAtlas ?? {} + if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled + if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox + if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity + if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip + if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation + } + if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) { + if (typeof legacy.hdrReflection.enabled === 'boolean') { + out.envAtlas.reflection = legacy.hdrReflection.enabled + } + } + if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') { + out.envAtlas.reflectionIntensity = out.envAtlas.intensity + } + if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') { + out.envAtlas.brightness = 1 + } + if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') { + out.envAtlas.contrast = 1 + } + if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') { + out.envAtlas.saturation = 1 + } + if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') { + out.envAtlas.toneMapping = 0 + } + if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') { + out.skybox.reflectionIntensity = out.skybox.intensity + } + if (out.grid && typeof out.grid.enabled !== 'boolean') { + out.grid.enabled = DEFAULT_POSTFX.grid.enabled + } + if (out.grid && typeof out.grid.colorX !== 'string') { + out.grid.colorX = DEFAULT_POSTFX.grid.colorX + } + if (out.grid && typeof out.grid.colorZ !== 'string') { + out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ + } + if (out.grid && typeof out.grid.colorMain !== 'string') { + out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain + } + if (out.grid && typeof out.grid.alphaX !== 'number') { + out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX + } + if (out.grid && typeof out.grid.alphaZ !== 'number') { + out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ + } + if (out.grid && typeof out.grid.alphaMain !== 'number') { + out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain + } + if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') { + out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled + } + if (out.grid && typeof out.grid.dotsColor !== 'string') { + out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor + } + if (out.grid && typeof out.grid.dotsAlpha !== 'number') { + out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha + } + if (out.grid && typeof out.grid.dotsDiameter !== 'number') { + out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter + } + if (out.grid && typeof out.grid.crossEnabled !== 'boolean') { + out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled + } + if (out.grid && typeof out.grid.crossColor !== 'string') { + out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor + } + if (out.grid && typeof out.grid.crossAlpha !== 'number') { + out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha + } + if (out.grid && typeof out.grid.crossLength !== 'number') { + out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength + } + if (out.grid && typeof out.grid.crossWidth !== 'number') { + out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth + } + return out as PostFxSettings +} diff --git a/apps/control-station/src/core/roverScene/sceneDocument.ts b/apps/control-station/src/core/roverScene/sceneDocument.ts new file mode 100644 index 0000000..b230a1e --- /dev/null +++ b/apps/control-station/src/core/roverScene/sceneDocument.ts @@ -0,0 +1,85 @@ +import {mergePostFxDefaults,type PostFxSettings} from './postFx'; +import donorFields from './environmentFields.json'; +export type SceneField={key:string;type:string;label:string;group:string;default?:unknown;min?:number;max?:number;step?:number;options?:{label:string;value:string}[];disabledIf?:string}; +export const environmentFields:SceneField[]=[...donorFields, + {key:'postFx.lut.textureUrl',type:'texture',label:'Таблица LUT',group:'PostProcess/LUT'}, + {key:'postFx.lighting.ambientIntensity',type:'sliderNumber',label:'Заполняющий свет',group:'PostProcess/Lighting',min:0,max:1,step:.01}, + {key:'postFx.grid.step',type:'sliderNumber',label:'Шаг основной сетки, м',group:'PostProcess/Grid',min:.01,max:10,step:.01}, + {key:'postFx.grid.diameter',type:'sliderNumber',label:'Диаметр сетки, м',group:'PostProcess/Grid',min:1,max:1000,step:1}, +]; +export const textureSlots=['diffuse','normal','metalness','roughness','opacity','emissive','ao'] as const; +export type TextureSlot=typeof textureSlots[number]; +export type MaterialSpec={name:string;color:string;metalness:number;roughness:number;opacity:number;reflectivity:number;clearCoat:number;clearCoatRoughness:number;emissive:string;emissiveIntensity:number;normalStrength:number;doubleSided:boolean;tiling:number;textures:Partial>}; +export type SceneDocument={schema:'missioncore.rover-scene/v1';model:'dcd006-v020-materials-v2';environmentVersion:2;postFx:PostFxSettings;cameraZoomStrength:number;cameraAutoRotate:boolean;materials:Record;assignments:Record}; +const material=(name:string,color:string,metalness:number,roughness:number):MaterialSpec=>({name,color,metalness,roughness,opacity:1,reflectivity:1,clearCoat:0,clearCoatRoughness:.3,emissive:'#000000',emissiveIntensity:0,normalStrength:1,doubleSided:false,tiling:1,textures:{}}); +export function defaultScene():SceneDocument{ + const postFx=mergePostFxDefaults(); + return {schema:'missioncore.rover-scene/v1',model:'dcd006-v020-materials-v2',environmentVersion:2,postFx,cameraZoomStrength:.04,cameraAutoRotate:false,assignments:{},materials:{ + 'rover/body':material('Корпус и подвеска','#2c2f31',.12,.53), + 'rover/rubber':material('Резина гусениц и катков','#202328',0,.72), + 'rover/track-steel':material('Нержавеющие проставки','#c4c8cd',1,.17), + 'rover/box':material('Бортовой ящик','#34373a',.08,.4), + 'rover/molle':material('MOLLE панель','#52575b',.3,.44), + 'rover/motor':material('Алюминий моторов','#95999c',.68,.38), + 'rover/hardware':material('Чёрный крепёж','#24272b',.45,.38), + 'rover/polymer':material('Пластик и кожухи','#24292d',0,.44), + 'rover/drive':material('Ведущие звёзды','#81652f',0,.46), + }}; +} +export function createMaterial(doc:SceneDocument,id:string):SceneDocument{ + if(!/^custom\/[a-zA-Z0-9-]{1,80}$/.test(id)||Object.hasOwn(doc.materials,id)||Object.keys(doc.materials).length>=128)return doc; + const used=new Set(Object.values(doc.materials).map(m=>m.name));let n=1;while(used.has(`Материал ${n}`))n++; + return {...doc,materials:{...doc.materials,[id]:material(`Материал ${n}`,'#808080',0,.5)}}; +} +export function assignMaterial(doc:SceneDocument,group:string,id:string):SceneDocument{ + if(!Object.hasOwn(defaultScene().materials,group)||!Object.hasOwn(doc.materials,id))return doc; + return {...doc,assignments:{...doc.assignments,[group]:id}}; +} +export function fieldValue(doc:SceneDocument,key:string):unknown{return key.split('.').reduce((v,k)=>v&&typeof v==='object'?(v as Record)[k]:undefined,doc);} +export function fieldDisabled(doc:SceneDocument,f:SceneField):boolean{ + const rule=f.disabledIf;if(!rule)return false; + if(rule.startsWith('!'))return !fieldValue(doc,rule.slice(1)); + const atlas=doc.postFx.envAtlas.enabled,sky=doc.postFx.skybox.enabled; + return ({ssaoDisabled:doc.postFx.ssao.type==='none',fogDisabled:doc.postFx.rendering.fog==='none',envAtlasBlocked:sky,skyboxBlocked:atlas,envAtlasFieldsDisabled:sky||!atlas,skyboxFieldsDisabled:atlas||!sky,gridDotsDisabled:!doc.postFx.grid.dotsEnabled,gridCrossDisabled:!doc.postFx.grid.crossEnabled} as Record)[rule]??false; +} +export function changeField(doc:SceneDocument,key:string,value:unknown):SceneDocument{ + const f=environmentFields.find(x=>x.key===key);if(!f||f.type==='action')return doc; + if(f.type==='texture'&&value!==null&&(typeof value!=='string'||value.length>1_000_000||!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(value)))return doc; + const old=fieldValue(doc,key); + if(typeof old==='number'){ + value=Number(value);if(!Number.isFinite(value))return doc; + if(f.min!==undefined)value=Math.max(f.min,value as number);if(f.max!==undefined)value=Math.min(f.max,value as number); + }else if(typeof old==='boolean'){if(typeof value!=='boolean')return doc;} + else if(f.type==='color'){if(typeof value!=='string'||!/^#[0-9a-f]{6}$/i.test(value))return doc;} + if(f.options&&!f.options.some(o=>o.value===String(value)))return doc; + if(f.type==='doubleSlider'){ + if(!Array.isArray(value)||value.length!==2||!value.every(Number.isFinite))return doc; + value=[Math.max(0,Math.min(100,value[0])),Math.max(0,Math.min(100,value[1]))].sort((a,b)=>a-b); + } + const result={...doc,postFx:structuredClone(doc.postFx)};const parts=key.split('.');const tail=parts.pop()!; + const target=parts.reduce((v,k)=>v[k],result as any);target[tail]=value; + if(key==='postFx.rendering.fogRange'){[result.postFx.rendering.fogStart,result.postFx.rendering.fogEnd]=value as [number,number];} + if(key==='postFx.envAtlas.enabled'&&value)result.postFx.skybox.enabled=false; + if(key==='postFx.skybox.enabled'&&value)result.postFx.envAtlas.enabled=false; + return result; +} +export const materialRanges:Record={metalness:[0,1],roughness:[0,1],opacity:[0,1],reflectivity:[0,1],clearCoat:[0,1],clearCoatRoughness:[0,1],emissiveIntensity:[0,10],normalStrength:[0,2],tiling:[.01,20]}; +export function textureBytes(doc:SceneDocument):number{return Object.values(doc.materials).reduce((sum,m)=>sum+Object.values(m.textures).reduce((n,t)=>n+(t?.data.length??0),0),0);} +export function parseScene(raw:unknown):SceneDocument{ + const r=raw as SceneDocument;if(!r||r.schema!=='missioncore.rover-scene/v1'||r.model!=='dcd006-v020-materials-v2')throw new Error('Файл сцены не подходит к этой модели.'); + let doc=defaultScene(); + for(const f of environmentFields){const v=fieldValue(r,f.key);if(v!==undefined)doc=changeField(doc,f.key,v);} + if(Object.keys(r.materials??{}).length>128)throw new Error('В сцене может быть не больше 128 материалов.'); + for(const id of Object.keys(r.materials??{}))if(id.startsWith('custom/'))doc=createMaterial(doc,id); + for(const [id,base] of Object.entries(doc.materials)){ + const m=r.materials?.[id];if(!m)continue; + if(typeof m.name==='string')base.name=m.name.trim().slice(0,120)||base.name; + for(const key of ['color','emissive'] as const)if(/^#[0-9a-f]{6}$/i.test(m[key]))base[key]=m[key]; + for(const [key,[lo,hi]] of Object.entries(materialRanges)){const v=m[key as keyof MaterialSpec];if(typeof v==='number'&&Number.isFinite(v))(base as any)[key]=Math.max(lo,Math.min(hi,v));} + if(typeof m.doubleSided==='boolean')base.doubleSided=m.doubleSided; + for(const slot of textureSlots){const t=m.textures?.[slot];if(!t)continue;if(typeof t.name!=='string'||typeof t.data!=='string'||t.data.length>12_000_000||!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(t.data))throw new Error('Некорректная текстура в файле сцены.');base.textures[slot]={name:t.name.slice(0,200),data:t.data};} + } + for(const [group,id] of Object.entries(r.assignments??{}))if(typeof id==='string')doc=assignMaterial(doc,group,id); + if(textureBytes(doc)>28_000_000)throw new Error('Суммарный объём текстур превышает 20 МБ.'); + return doc; +} diff --git a/apps/control-station/src/core/roverScene/sceneMigration.ts b/apps/control-station/src/core/roverScene/sceneMigration.ts new file mode 100644 index 0000000..e5297e0 --- /dev/null +++ b/apps/control-station/src/core/roverScene/sceneMigration.ts @@ -0,0 +1,21 @@ +import {defaultScene,parseScene,type SceneDocument} from './sceneDocument'; + +// Fingerprint of the first editor's withdrawn neutral preset. Match the entire +// environment: independently edited environments must never be reset on load. +function withdrawnEnvironment(){ + const fx=defaultScene().postFx; + Object.assign(fx.lighting,{exposure:1.1,skyBoxIntensity:1,ambientIntensity:.22}); + Object.assign(fx.envAtlas,{reflectionIntensity:1,brightness:1.2}); + Object.assign(fx.rendering,{backgroundColor:'#202227',fog:'none',toneMapping:4}); + Object.assign(fx.grid,{colorMain:'#747982',colorX:'#93969c',colorZ:'#93969c',alphaMain:.25,alphaX:.35,alphaZ:.35,fadeStart:2,fadeEnd:5,step:.5,diameter:12}); + fx.chromaticAberration.enabled=false;fx.vignette.enabled=false;fx.grading.enabled=false;fx.bloom.enabled=false; + Object.assign(fx.directionalLight,{intensity:1.8,azimuth:135,elevation:45}); + Object.assign(fx.shadowCatcher,{size:4,lightIntensity:.5,shadowIntensity:.25}); + return fx; +} +export function loadStoredScene(raw:unknown):{document:SceneDocument;updated:boolean;restoredEnvironment:boolean}{ + const doc=parseScene(raw); + const updated=(raw as Partial).environmentVersion!==2; + const restoredEnvironment=updated&&JSON.stringify(doc.postFx)===JSON.stringify(withdrawnEnvironment()); + return {document:restoredEnvironment?{...doc,postFx:defaultScene().postFx}:doc,updated,restoredEnvironment}; +} diff --git a/apps/control-station/src/core/roverScene/sceneStorage.ts b/apps/control-station/src/core/roverScene/sceneStorage.ts new file mode 100644 index 0000000..b756197 --- /dev/null +++ b/apps/control-station/src/core/roverScene/sceneStorage.ts @@ -0,0 +1,23 @@ +import type {SceneDocument} from './sceneDocument'; +import {loadStoredScene} from './sceneMigration'; +const key=(vehicle:string)=>`${vehicle}:dcd006-v020-materials-v2`; +function database():Promise{return new Promise((resolve,reject)=>{const r=indexedDB.open('missioncore-rover-scenes',1);r.onupgradeneeded=()=>r.result.createObjectStore('scenes');r.onerror=()=>reject(r.error);r.onblocked=()=>reject(new Error('Хранилище сцены занято другим окном.'));r.onsuccess=()=>resolve(r.result);});} +export async function readScene(vehicle:string):Promise{ + const db=await database();try{ + const raw=await new Promise((resolve,reject)=>{const r=db.transaction('scenes').objectStore('scenes').get(key(vehicle));r.onerror=()=>reject(r.error);r.onsuccess=()=>resolve(r.result);}); + if(!raw)return null; + const result=loadStoredScene(raw); + if(result.updated)await new Promise((resolve,reject)=>{ + const tx=db.transaction('scenes','readwrite'),store=tx.objectStore('scenes'); + if(result.restoredEnvironment)store.put(raw,`${key(vehicle)}:before-environment-v2`); + store.put(result.document,key(vehicle));tx.oncomplete=()=>resolve();tx.onabort=()=>reject(tx.error);tx.onerror=()=>reject(tx.error); + }); + return result.document; + }finally{db.close();} +} +export async function saveScene(vehicle:string,document:SceneDocument):Promise{const db=await database();try{await new Promise((resolve,reject)=>{const tx=db.transaction('scenes','readwrite');tx.objectStore('scenes').put(document,key(vehicle));tx.oncomplete=()=>resolve();tx.onabort=()=>reject(tx.error);tx.onerror=()=>reject(tx.error);});}finally{db.close();}} +export async function textureFromFile(file:File):Promise<{name:string;data:string}>{ + if(!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>8_000_000)throw new Error('Выберите PNG, JPEG или WebP до 8 МБ.'); + const bitmap=await createImageBitmap(file);try{if(bitmap.width>4096||bitmap.height>4096)throw new Error('Максимальный размер текстуры — 4096 × 4096.');}finally{bitmap.close();} + return new Promise((resolve,reject)=>{const r=new FileReader();r.onerror=()=>reject(r.error);r.onload=()=>resolve({name:file.name,data:String(r.result)});r.readAsDataURL(file);}); +} diff --git a/apps/control-station/src/core/roverScene/useSceneDocument.ts b/apps/control-station/src/core/roverScene/useSceneDocument.ts new file mode 100644 index 0000000..f5156d4 --- /dev/null +++ b/apps/control-station/src/core/roverScene/useSceneDocument.ts @@ -0,0 +1,22 @@ +import {useCallback,useEffect,useRef,useState} from 'react'; +import {defaultScene,parseScene,type SceneDocument} from './sceneDocument'; +import {readScene,saveScene} from './sceneStorage'; +let writes=Promise.resolve(); +const enqueue=(vehicle:string,doc:SceneDocument)=>{const next=writes.catch(()=>{}).then(()=>saveScene(vehicle,doc));writes=next;return next;}; +export function useSceneDocument(vehicle:string){ + const [document,setDocument]=useState(defaultScene),[ready,setReady]=useState(false),[revision,setRevision]=useState(0); + const [saveState,setSaveState]=useState<'loading'|'saved'|'saving'|'error'>('loading'),[error,setError]=useState(''); + const latest=useRef({document,revision,vehicle});latest.current={document,revision,vehicle}; + const savedRevision=useRef(0); + useEffect(()=>{let cancelled=false;readScene(vehicle).then(value=>{if(!cancelled){setDocument(value??defaultScene());setSaveState('saved');}}).catch(()=>{if(!cancelled){setError('Не удалось прочитать сохранённую сцену. Изменения можно экспортировать в JSON.');setSaveState('error');}}).finally(()=>{if(!cancelled)setReady(true);});return()=>{cancelled=true;};},[vehicle]); + const update=useCallback((fn:(doc:SceneDocument)=>SceneDocument)=>{setDocument(d=>fn(d));setRevision(v=>v+1);setSaveState('saving');},[]); + useEffect(()=>{ + if(!ready||revision===0)return; + let cancelled=false;const timer=setTimeout(()=>{enqueue(vehicle,document).then(()=>{savedRevision.current=revision;if(!cancelled)setSaveState('saved');}).catch(()=>{if(!cancelled){setSaveState('error');setError('Не удалось сохранить сцену на этом компьютере. Экспортируйте JSON, чтобы сохранить изменения.');}});},400); + return()=>{cancelled=true;clearTimeout(timer);}; + },[ready,revision,vehicle,document]); + useEffect(()=>()=>{const v=latest.current;if(v.revision>savedRevision.current)void enqueue(v.vehicle,v.document).catch(()=>{});},[]); + const importDocument=async(file:File)=>{try{if(file.size>32_000_000)throw new Error('Файл сцены больше 32 МБ.');const next=parseScene(JSON.parse(await file.text()));update(()=>next);}catch(e){setError(e instanceof Error?e.message:'Не удалось загрузить сцену.');}}; + const exportDocument=()=>{const blob=new Blob([JSON.stringify(document,null,2)],{type:'application/json'});const url=URL.createObjectURL(blob);const a=globalThis.document.createElement('a');a.href=url;a.download='rover-scene.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}; + return {document,ready,saveState,error,setError,update,importDocument,exportDocument}; +} diff --git a/apps/control-station/test/roverScene.test.mjs b/apps/control-station/test/roverScene.test.mjs new file mode 100644 index 0000000..066b263 --- /dev/null +++ b/apps/control-station/test/roverScene.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {build} from 'esbuild'; +const result=await build({entryPoints:[new URL('../src/core/roverScene/sceneDocument.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'}); +const {defaultScene,environmentFields,changeField,fieldDisabled,parseScene,createMaterial,assignMaterial}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64')); +test('all donor camera and environment fields remain exposed, frame styling excluded',t=>{ + const source=new URL('../../../../NODEDC_ENGINE_INFRA/nodedc-source/src/driveinspector/nodes/ThreeDAsset.definition.ts',import.meta.url); + if(!fs.existsSync(source)){t.skip('Donor checkout not installed; pinned catalog remains in repo');return;} + const donor=fs.readFileSync(source,'utf8'); + const keys=[...donor.matchAll(/key: '(postFx\.[^']+|cameraZoomStrength|cameraAutoRotate|cameraResetTs)'/g)].map(m=>m[1]); + assert.equal(keys.length,109); + for(const key of keys)assert.ok(environmentFields.some(f=>f.key===key),key); + assert.ok(!environmentFields.some(f=>f.key==='nodeName')); +}); +test('documents are independent, scene edits preserve material texture references',()=>{ + const a=defaultScene(),b=defaultScene();a.materials['rover/body'].color='#123456';assert.notEqual(a.materials['rover/body'].color,b.materials['rover/body'].color); + const edited=changeField(a,'postFx.lighting.exposure',1.5);assert.equal(edited.postFx.lighting.exposure,1.5);assert.equal(a.postFx.lighting.exposure,defaultScene().postFx.lighting.exposure);assert.equal(edited.materials,a.materials); +}); +test('numeric enums, bounds, invalid color and unknown paths are validated',()=>{ + const a=defaultScene();assert.equal(changeField(a,'postFx.rendering.toneMapping','3').postFx.rendering.toneMapping,3); + assert.equal(changeField(a,'postFx.grid.step',0).postFx.grid.step,.01); + for(const [key,value] of [['__proto__.x',1],['postFx.grid.step',NaN],['postFx.grid.colorMain','red'],['postFx.rendering.fog','invalid']])assert.equal(changeField(a,key,value),a); +}); +test('fog interval, atlas/skybox exclusion and field availability stay coherent',()=>{ + let a=changeField(defaultScene(),'postFx.rendering.fogRange',[18,3]);assert.deepEqual(a.postFx.rendering.fogRange,[3,18]);assert.equal(a.postFx.rendering.fogStart,3);assert.equal(a.postFx.rendering.fogEnd,18); + a=changeField(a,'postFx.skybox.enabled',true);assert.equal(a.postFx.envAtlas.enabled,false); + assert.equal(fieldDisabled(a,environmentFields.find(f=>f.key==='postFx.envAtlas.enabled')),true); +}); +test('scene JSON round trips materials, texture data and environment without authority',()=>{ + const a=defaultScene();a.materials['rover/box'].textures.diffuse={name:'test.png',data:'data:image/png;base64,YWJj'};a.materials['rover/box'].roughness=.28; + assert.deepEqual(parseScene(JSON.parse(JSON.stringify(a))),a); + assert.throws(()=>parseScene({schema:'other'})); + assert.throws(()=>parseScene({...a,model:'wrong'})); + assert.equal('armed' in a,false); +}); +test('import rejects texture URLs and normalizes out-of-range PBR values',()=>{ + const a=defaultScene();a.materials['rover/box'].roughness=20;assert.equal(parseScene(a).materials['rover/box'].roughness,1); + a.materials['rover/box'].textures.normal={name:'remote',data:'https://unknown/texture'};assert.throws(()=>parseScene(a)); +}); +test('editing excludes drive bindings and key controls without changing arm authority',()=>{ + const view=fs.readFileSync(new URL('../src/components/rover/RoverView.tsx',import.meta.url),'utf8'); + assert.match(view,/if\(!c.ready\|\|!active\|\|open\|\|editing/);assert.match(view,/c.ready&&!editing/);assert.match(view,/c.pauseInput\(\);setHeld/); + const editor=fs.readFileSync(new URL('../src/components/rover/editor/RoverScene.tsx',import.meta.url),'utf8');assert.doesNotMatch(editor,/\.arm\(|setDemand|\/api\/v1\/fleet/); +}); +test('export manifest retains complete geometry and all editable material groups',()=>{ + const manifest=JSON.parse(fs.readFileSync(new URL('../public/rover-scene/dcd006-v020.json',import.meta.url),'utf8')); + assert.equal(manifest.source_object_count,1426);assert.equal(manifest.render_mesh_count,9);assert.equal(manifest.source_saved,false);assert.match(manifest.uv,/cube/); + assert.deepEqual(Object.keys(manifest.materials).sort(),Object.keys(defaultScene().materials).map(k=>k.replace('rover/','')).sort()); +}); + +test('named custom materials and assignments survive JSON and reject unknown groups',()=>{ + let a=createMaterial(defaultScene(),'custom/example'); + a.materials['custom/example'].name='Мой металл';a=assignMaterial(a,'rover/body','custom/example'); + assert.deepEqual(parseScene(JSON.parse(JSON.stringify(a))),a); + assert.equal(assignMaterial(a,'unknown','custom/example'),a); + assert.equal(assignMaterial(a,'rover/body','missing'),a); + assert.equal(createMaterial(a,'__proto__'),a); + const legacy=defaultScene();delete legacy.assignments;assert.deepEqual(parseScene(legacy).assignments,{}); +}); +test('default environment is the original NodeDC preset without neutral overrides',()=>{ + const a=defaultScene();assert.equal(a.postFx.chromaticAberration.enabled,true); + assert.equal(a.postFx.rendering.backgroundColor,'#111113'); + assert.equal(a.postFx.lighting.ambientIntensity,0); +}); diff --git a/apps/control-station/test/roverSceneMigration.test.mjs b/apps/control-station/test/roverSceneMigration.test.mjs new file mode 100644 index 0000000..b023ae0 --- /dev/null +++ b/apps/control-station/test/roverSceneMigration.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {build} from 'esbuild'; +const bundle=await build({stdin:{contents:"export * from './src/core/roverScene/sceneDocument'; export * from './src/core/roverScene/sceneMigration';",resolveDir:process.cwd()},bundle:true,write:false,platform:'node',format:'esm'}); +const {defaultScene,loadStoredScene,parseScene}=await import('data:text/javascript;base64,'+Buffer.from(bundle.outputFiles[0].contents).toString('base64')); +// Historical settings released by the first editor, independent of migration code. +function savedFirstEditor(){ + const d=defaultScene();delete d.environmentVersion; + d.postFx={...d.postFx, + lighting:{exposure:1.1,skyBoxIntensity:1,ambientIntensity:.22}, + envAtlas:{...d.postFx.envAtlas,reflectionIntensity:1,brightness:1.2}, + rendering:{...d.postFx.rendering,backgroundColor:'#202227',fog:'none',toneMapping:4}, + grid:{...d.postFx.grid,colorMain:'#747982',colorX:'#93969c',colorZ:'#93969c',alphaMain:.25,alphaX:.35,alphaZ:.35,fadeStart:2,fadeEnd:5,step:.5,diameter:12}, + chromaticAberration:{...d.postFx.chromaticAberration,enabled:false},vignette:{...d.postFx.vignette,enabled:false},grading:{...d.postFx.grading,enabled:false},bloom:{...d.postFx.bloom,enabled:false}, + directionalLight:{...d.postFx.directionalLight,intensity:1.8,azimuth:135,elevation:45},shadowCatcher:{...d.postFx.shadowCatcher,size:4,lightIntensity:.5,shadowIntensity:.25} + }; + return d; +} +test('previous saved neutral preset migrates to NodeDC while retaining material work',()=>{ + const raw=savedFirstEditor();raw.materials['rover/body'].name='Мой корпус';raw.materials['rover/body'].roughness=.73;raw.materials['rover/body'].textures.diffuse={name:'my.png',data:'data:image/png;base64,YWJj'}; + raw.assignments['rover/box']='rover/body';raw.cameraZoomStrength=.1; + const original=structuredClone(raw),r=loadStoredScene(raw); + assert.equal(r.restoredEnvironment,true);assert.equal(r.updated,true); + assert.deepEqual(r.document.postFx,defaultScene().postFx); + assert.deepEqual(r.document.materials,raw.materials);assert.deepEqual(r.document.assignments,raw.assignments);assert.equal(r.document.cameraZoomStrength,.1); + assert.deepEqual(raw,original,'backup must retain the exact input'); + assert.equal(loadStoredScene(r.document).updated,false,'migration runs once'); +}); +test('a single custom environment edit prevents a destructive reset',()=>{ + const raw=savedFirstEditor();raw.postFx.lighting.exposure=1.3; + const r=loadStoredScene(raw);assert.equal(r.restoredEnvironment,false);assert.deepEqual(r.document.postFx,raw.postFx); +}); +test('a deliberate new gray scene and explicit JSON imports retain their appearance',()=>{ + const raw=savedFirstEditor();raw.environmentVersion=2; + assert.equal(loadStoredScene(raw).restoredEnvironment,false); + delete raw.environmentVersion; + const imported=parseScene(raw);assert.equal(imported.environmentVersion,2);assert.deepEqual(imported.postFx,raw.postFx); + assert.equal(loadStoredScene(imported).restoredEnvironment,false); +}); +test('already restored legacy NodeDC scene is only versioned, not altered',()=>{ + const raw=defaultScene();delete raw.environmentVersion;const r=loadStoredScene(raw); + assert.equal(r.updated,true);assert.equal(r.restoredEnvironment,false);assert.deepEqual(r.document.postFx,raw.postFx); +}); diff --git a/docs/adr/0048_ROVER_SCENE_EDITOR.md b/docs/adr/0048_ROVER_SCENE_EDITOR.md new file mode 100644 index 0000000..b17e7a5 --- /dev/null +++ b/docs/adr/0048_ROVER_SCENE_EDITOR.md @@ -0,0 +1,66 @@ +# Rover scene material and environment editor + +Owner request: 2026-09-25. The operator adjusts the appearance of the selected +vehicle without leaving its observation viewport. The pencil in the existing +3D View header enters an editing mode. A bottom material palette and movable, +modeless material/environment inspectors apply changes immediately. + +The earlier combined control/design modal was explicitly superseded by the +owner. Driving settings retain their existing modal. A separate workspace +would hide the live scene and duplicate navigation; the admitted surface is an +edit mode of the existing rover view, with no new product root. + +The scene document belongs to vehicle + model revision, persists in IndexedDB +on this operator computer, includes imported texture data, and can be exported +or imported as JSON. Loading and persistence failures remain visible; closing +an inspector never waits for saving. This is appearance only, not a firmware, +calibration or hardware profile. Editing pauses local drive input and prevents +new drive input until exit; it never arms a vehicle. + +NodeDC ThreeDAsset.definition provides the environment field catalog and +PlayCanvas remains the renderer. The NodeDC inspector chrome is not copied. +Design Guideline Window (modeless, draggable), Inspector, ControlRow, +InspectorSelectField, RangeControl, ColorField, Switch, Button, IconButton and +ToastStack own all controls and surfaces. Canonical edit/layers/globe icons +identify edit, materials and environment. Material thumbnails are domain PBR +previews inside canonical buttons. + +The v020 Blender file is an immutable source. Export retains separate semantic +material groups and UVs; clicking a rendered surface selects its shared material. +The palette explicitly states that edits affect every surface using that +material. Appearance groups include chassis, rubber, track steel, enclosure, +MOLLE, motor aluminum, black hardware and accessories. + +Acceptance: donor field coverage, scene validation/persistence isolation, +renderer resource cleanup, edit/control exclusion, TypeScript/unit/build and +browser QA for picking, live edits, dragging, normal/expanded layout, keyboard, +texture loading, persistence and return to observation. No physical motion is +part of editor acceptance. + +Owner refinement: the 3D header overlays the viewport with no opaque strip. +The original NodeDC environment preset remains the default and is restored +through the environment inspector, preserving materials. Every material has an +editable display name. A permanent final plus tile creates a new PBR material; +explicit assignment applies it to the picked semantic group. IDs remain stable +when renamed. Custom materials and assignments persist in the same document. + +Verified on 2026-09-25: TypeScript/production build, all 972 frontend tests, +normal and expanded browser layout, transparent header, the NodeDC reset control, +GPU picking, material naming/creation/assignment, draggable inspector and Escape. +A synthetic checker texture was uploaded, rendered and retained with its custom +material, name and assignment after a page reload. The texture was then removed +and the enclosure's original material reassigned. Browser error/warning log was +empty. No motor commands were sent; the canonical service remains on port 8000. +The inherited renderer still reduces MSAA to one sample. Local scene storage is +not cross-computer synchronization. Both bounds are recorded in MISSIONCOR-85. + +Follow-up: the owner still saw the withdrawn gray preset after clearing browser +cache. Changing defaults and using the reset in one browser did not migrate +other saved IndexedDB scene documents. Added environmentVersion 2 and a narrow +load migration: only the exact original neutral environment is replaced with +NodeDC defaults. Material edits, textures, assignments and camera options survive. +An atomic IndexedDB transaction retains the old document under +`:before-environment-v2` before replacing it. Individually edited environments +and explicit JSON imports are preserved; the version prevents future resets. +Regression tests cover the legacy scene, material preservation, idempotence, +custom environments, deliberate gray scenes, imports and already restored scenes. diff --git a/tools/rover-scene/export_rover.py b/tools/rover-scene/export_rover.py index 401ef69..de8ced8 100644 --- a/tools/rover-scene/export_rover.py +++ b/tools/rover-scene/export_rover.py @@ -1,49 +1,70 @@ -"""Immutable-source evaluated geometry export, owner-selected complete v020.""" -import bpy,json,hashlib,struct +"""Evaluated v020 geometry, semantic PBR groups and UVs; never saves the source.""" +import bpy, json, hashlib +from collections import defaultdict from pathlib import Path -from mathutils import Vector,Matrix +from mathutils import Vector, Matrix root=Path.cwd();source=Path(bpy.data.filepath) -donor=root.parent/'NODEDC_ENGINE_INFRA/nodedc-source/public/3dassetnode/model.glb' -with donor.open('rb') as f: - f.read(12);n,t=struct.unpack('