├── .gitignore ├── README.md ├── example ├── curve.bin ├── curve.gltf ├── index.html ├── index.js └── lib │ └── three.module.js ├── exporter └── __init__.py ├── gltf-transform-curve.config.mjs ├── gltf_curve_exporter.zip └── loader └── GLTFCurveExtension.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | **/.DS_Store 3 | *.log 4 | *.log.* 5 | *.env 6 | exporter/__pycache__/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # glTF Curve Exporter and Threejs Loader 2 | 3 | This project provides a set of tools for exporting and importing curve data in glTF files, specifically designed for use with Blender and Three.js. It consists of two main components: 4 | 5 | 1. A Blender extension for exporting curve data in glTF files 6 | 2. A Three.js addon for loading and rendering these curves in web applications 7 | 8 | ## Blender Extension: glTF Curve Exporter 9 | 10 | ### Features 11 | - Exports Bezier, NURBS, and Poly curves from Blender 12 | - Preserves curve data including control points, handles, and knots 13 | - Supports cyclic curves 14 | - Integrates seamlessly with Blender's glTF export process 15 | 16 | ### Installation 17 | 1. Download the `gltf_curve_exporter.zip` file from the releases section of this repository 18 | 2. Open Blender and go to Edit > Preferences > Add-ons 19 | 3. Click "Install" and select the downloaded `gltf_curve_exporter.zip` file 20 | 4. Enable the addon by checking the box next to "Import-Export: glTF Curve Exporter Extension" 21 | 22 | Note: If you're using an older version of Blender or prefer manual installation, you can extract the `gltf_curve_exporter.py` file from the ZIP and install it directly. 23 | 24 | ### Usage 25 | 1. Create your curves in Blender 26 | 2. When exporting your scene as glTF, ensure the "Export Curves" option is enabled in the export settings 27 | 3. Export your scene as usual 28 | 29 | ### Installation 30 | 1. Copy the `GLTFCurveExtension.js` file to your project 31 | 2. Import the extension in your Three.js project 32 | 3. Add the extension to your GLTFLoader: 33 | 34 | ### Support with GLTF-Transform 35 | `gltf-transform optimize cube.glb cube.glb --config /path/to/gltf-transform-curve.config.mjs` 36 | 37 | ```javascript 38 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; 39 | import { GLTFCurveExtension } from './path/to/GLTFCurveExtension.js'; 40 | 41 | const loader = new GLTFLoader(); 42 | loader.register(parser => new GLTFCurveExtension(parser)); 43 | 44 | loader.load('path/to/your/model.gltf', (gltf) => { 45 | // Your scene is now loaded with curve data 46 | scene.add(gltf.scene); 47 | }); 48 | ``` 49 | 50 | ## Important Notes 51 | - Ensure that you're using compatible versions of Blender (4.2.0 beta or later recommended), Three.js, and the extensions 52 | - The Three.js addon requires the `NURBSCurve` class from Three.js examples 53 | 54 | ## Known Issues 55 | - Cyclic NURBS curves may not render perfectly closed in Three.js. This is due to differences in how Blender and Three.js handle NURBS curves. We're working on improving this, but for now, you may need to implement additional logic to ensure perfect closure for cyclic NURBS curves in your Three.js application. 56 | 57 | ## Contributing 58 | Contributions are welcome! Please feel free to submit a Pull Request. 59 | 60 | ## License 61 | This project is licensed under the MIT License - see the LICENSE file for details. 62 | 63 | ## Support 64 | If you encounter any issues or have questions, please file an issue on the GitHub repository. 65 | 66 | ## Troubleshooting 67 | - If the addon doesn't appear in Blender's addon list after installation, make sure you're using a compatible version of Blender and that the ZIP file wasn't corrupted during download. 68 | - If curves are not appearing in your Three.js scene, ensure that the "Export Curves" option was enabled during the glTF export from Blender. 69 | - Check the console for any error messages related to the GLTFCurveExtension. 70 | - Verify that the curve data is present in the exported glTF file by inspecting its contents. 71 | 72 | ## 🧑‍🎨 Maintainers : 73 | 74 | - [`twitter 🐈‍⬛ @onirenaud`](https://twitter.com/onirenaud) 75 | - [`twitter @utsuboco`](https://twitter.com/utsuboco) 76 | -------------------------------------------------------------------------------- /example/curve.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utsuboco/gltf_curve_exporter/5b00ba9385f9e5d9e9b6ab9b93c75cf5f035d977/example/curve.bin -------------------------------------------------------------------------------- /example/curve.gltf: -------------------------------------------------------------------------------- 1 | { 2 | "asset":{ 3 | "generator":"Khronos glTF Blender I/O v4.2.53", 4 | "version":"2.0" 5 | }, 6 | "extensionsUsed":[ 7 | "UTSUBO_curve_extension" 8 | ], 9 | "scene":0, 10 | "scenes":[ 11 | { 12 | "name":"Scene", 13 | "nodes":[ 14 | 0, 15 | 1, 16 | 2, 17 | 3 18 | ] 19 | } 20 | ], 21 | "nodes":[ 22 | { 23 | "extensions":{ 24 | "UTSUBO_curve_extension":{ 25 | "splines":[ 26 | { 27 | "type":"BEZIER", 28 | "points":[ 29 | { 30 | "co":[ 31 | -1, 32 | 0, 33 | 0 34 | ], 35 | "handle_left":[ 36 | -1.5, 37 | -0.5, 38 | 0 39 | ], 40 | "handle_right":[ 41 | -0.5, 42 | 0.5, 43 | 0 44 | ] 45 | }, 46 | { 47 | "co":[ 48 | 1, 49 | 0, 50 | 0 51 | ], 52 | "handle_left":[ 53 | 0, 54 | 0, 55 | 0 56 | ], 57 | "handle_right":[ 58 | 2, 59 | 0, 60 | 0 61 | ] 62 | } 63 | ], 64 | "use_cyclic_u":false, 65 | "resolution_u":12 66 | } 67 | ], 68 | "dimensions":"3D" 69 | } 70 | }, 71 | "name":"B\u00e9zierCurve", 72 | "scale":[ 73 | 7.688710689544678, 74 | 7.688710689544678, 75 | 7.688710689544678 76 | ] 77 | }, 78 | { 79 | "extensions":{ 80 | "UTSUBO_curve_extension":{ 81 | "splines":[ 82 | { 83 | "type":"BEZIER", 84 | "points":[ 85 | { 86 | "co":[ 87 | -1, 88 | 0, 89 | 0 90 | ], 91 | "handle_left":[ 92 | -1, 93 | -0.5521252155303955, 94 | 0 95 | ], 96 | "handle_right":[ 97 | -1, 98 | 0.5521252155303955, 99 | 0 100 | ] 101 | }, 102 | { 103 | "co":[ 104 | 0, 105 | 1, 106 | 0 107 | ], 108 | "handle_left":[ 109 | -0.5521252155303955, 110 | 1, 111 | 0 112 | ], 113 | "handle_right":[ 114 | 0.5521252155303955, 115 | 1, 116 | 0 117 | ] 118 | }, 119 | { 120 | "co":[ 121 | 1, 122 | 0, 123 | 0 124 | ], 125 | "handle_left":[ 126 | 1, 127 | 0.5521252155303955, 128 | 0 129 | ], 130 | "handle_right":[ 131 | 1, 132 | -0.5521252155303955, 133 | 0 134 | ] 135 | }, 136 | { 137 | "co":[ 138 | 0, 139 | -1, 140 | 0 141 | ], 142 | "handle_left":[ 143 | 0.5521252155303955, 144 | -1, 145 | 0 146 | ], 147 | "handle_right":[ 148 | -0.5521252155303955, 149 | -1, 150 | 0 151 | ] 152 | } 153 | ], 154 | "use_cyclic_u":true, 155 | "resolution_u":12 156 | } 157 | ], 158 | "dimensions":"3D" 159 | } 160 | }, 161 | "name":"B\u00e9zierCircle", 162 | "scale":[ 163 | 3.8486528396606445, 164 | 3.8486528396606445, 165 | 3.8486528396606445 166 | ] 167 | }, 168 | { 169 | "extensions":{ 170 | "UTSUBO_curve_extension":{ 171 | "splines":[ 172 | { 173 | "type":"NURBS", 174 | "points":[ 175 | { 176 | "co":[ 177 | 0.9779090881347656, 178 | 2.662904739379883, 179 | 7.832311630249023 180 | ] 181 | }, 182 | { 183 | "co":[ 184 | 3.1556456089019775, 185 | 3.568917989730835, 186 | 5.607099533081055 187 | ] 188 | }, 189 | { 190 | "co":[ 191 | -0.932328462600708, 192 | 3.6532223224639893, 193 | 5.292664527893066 194 | ] 195 | }, 196 | { 197 | "co":[ 198 | -3.8731603622436523, 199 | 3.566297769546509, 200 | 5.4377851486206055 201 | ] 202 | }, 203 | { 204 | "co":[ 205 | -3.980557918548584, 206 | 4.202625751495361, 207 | 3.7992918491363525 208 | ] 209 | }, 210 | { 211 | "co":[ 212 | -3.865211009979248, 213 | 7.241365432739258, 214 | 2.273794651031494 215 | ] 216 | }, 217 | { 218 | "co":[ 219 | -3.4528701305389404, 220 | 5.780352592468262, 221 | 7.697783470153809 222 | ] 223 | }, 224 | { 225 | "co":[ 226 | -4.164371013641357, 227 | -2.0784413814544678, 228 | 7.500043869018555 229 | ] 230 | }, 231 | { 232 | "co":[ 233 | -4.040891647338867, 234 | 2.940016508102417, 235 | 6.203649997711182 236 | ] 237 | }, 238 | { 239 | "co":[ 240 | -3.2230958938598633, 241 | 0.10303139686584473, 242 | 3.805034637451172 243 | ] 244 | }, 245 | { 246 | "co":[ 247 | -1.0818065404891968, 248 | 5.012454986572266, 249 | -0.23465251922607422 250 | ] 251 | }, 252 | { 253 | "co":[ 254 | -3.7239341735839844, 255 | 5.814431190490723, 256 | 0.7549352645874023 257 | ] 258 | }, 259 | { 260 | "co":[ 261 | -5.38651704788208, 262 | 2.639878988265991, 263 | 3.520345687866211 264 | ] 265 | }, 266 | { 267 | "co":[ 268 | -2.675334930419922, 269 | -1.5560362339019775, 270 | 4.469257831573486 271 | ] 272 | }, 273 | { 274 | "co":[ 275 | -1.6753350496292114, 276 | -1.5560362339019775, 277 | 4.469257831573486 278 | ] 279 | }, 280 | { 281 | "co":[ 282 | -0.6753350496292114, 283 | -1.5560362339019775, 284 | 4.469257831573486 285 | ] 286 | }, 287 | { 288 | "co":[ 289 | 0.3246649503707886, 290 | -1.5560362339019775, 291 | 4.469257831573486 292 | ] 293 | }, 294 | { 295 | "co":[ 296 | 1.3246649503707886, 297 | -1.5560362339019775, 298 | 4.469257831573486 299 | ] 300 | } 301 | ], 302 | "use_cyclic_u":false, 303 | "resolution_u":12, 304 | "order_u":5 305 | } 306 | ], 307 | "dimensions":"3D" 308 | } 309 | }, 310 | "name":"NurbsPath" 311 | }, 312 | { 313 | "extensions":{ 314 | "UTSUBO_curve_extension":{ 315 | "splines":[ 316 | { 317 | "type":"BEZIER", 318 | "points":[ 319 | { 320 | "co":[ 321 | -1, 322 | 0, 323 | 0 324 | ], 325 | "handle_left":[ 326 | -1, 327 | -0.5521252155303955, 328 | 0 329 | ], 330 | "handle_right":[ 331 | -1, 332 | 0.5521252155303955, 333 | 0 334 | ] 335 | }, 336 | { 337 | "co":[ 338 | 0, 339 | 1, 340 | 0 341 | ], 342 | "handle_left":[ 343 | -0.5521252155303955, 344 | 1, 345 | 0 346 | ], 347 | "handle_right":[ 348 | 0.5521252155303955, 349 | 1, 350 | 0 351 | ] 352 | }, 353 | { 354 | "co":[ 355 | 1, 356 | 0, 357 | 0 358 | ], 359 | "handle_left":[ 360 | 1, 361 | 0.5521252155303955, 362 | 0 363 | ], 364 | "handle_right":[ 365 | 1, 366 | -0.5521252155303955, 367 | 0 368 | ] 369 | }, 370 | { 371 | "co":[ 372 | 0, 373 | -1, 374 | 0 375 | ], 376 | "handle_left":[ 377 | 0.5521252155303955, 378 | -1, 379 | 0 380 | ], 381 | "handle_right":[ 382 | -0.5521252155303955, 383 | -1, 384 | 0 385 | ] 386 | } 387 | ], 388 | "use_cyclic_u":true, 389 | "resolution_u":12 390 | } 391 | ], 392 | "dimensions":"3D" 393 | } 394 | }, 395 | "name":"B\u00e9zierCircle.001", 396 | "rotation":[ 397 | 0.7071068286895752, 398 | 0, 399 | 0, 400 | 0.7071068286895752 401 | ], 402 | "scale":[ 403 | 2.548215627670288, 404 | 2.548215627670288, 405 | 2.548215627670288 406 | ], 407 | "translation":[ 408 | 0, 409 | 6.207799911499023, 410 | 0 411 | ] 412 | } 413 | ] 414 | } 415 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | three.js GLTF Curve Extension 5 | 6 | 9 | 27 | 28 | 29 | 30 |
31 | 32 | 41 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import WebGPU from 'three/addons/capabilities/WebGPU.js'; 4 | import WebGL from 'three/addons/capabilities/WebGL.js'; 5 | 6 | import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js'; 7 | 8 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; 9 | import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; 10 | 11 | import { GLTFCurveExtension } from '../loader/GLTFCurveExtension.js'; 12 | 13 | let camera, scene, renderer; 14 | 15 | init(); 16 | render(); 17 | 18 | function init() { 19 | if (WebGPU.isAvailable() === false && WebGL.isWebGL2Available() === false) { 20 | document.body.appendChild(WebGPU.getErrorMessage()); 21 | 22 | throw new Error('No WebGPU or WebGL2 support'); 23 | } 24 | 25 | const container = document.createElement('div'); 26 | document.body.appendChild(container); 27 | 28 | camera = new THREE.PerspectiveCamera( 29 | 45, 30 | window.innerWidth / window.innerHeight, 31 | 0.25, 32 | 40 33 | ); 34 | camera.position.set(0, 10, -20); 35 | 36 | scene = new THREE.Scene(); 37 | 38 | const loader = new GLTFLoader().setPath('./'); 39 | 40 | loader.register((parser) => new GLTFCurveExtension(parser)); 41 | 42 | loader.load('curve.gltf', async function (gltf) { 43 | await renderer.compileAsync(gltf.scene, camera); 44 | console.log(gltf.scene); 45 | scene.add(gltf.scene); 46 | 47 | render(); 48 | }); 49 | 50 | renderer = new WebGPURenderer({ antialias: true }); 51 | renderer.setPixelRatio(window.devicePixelRatio); 52 | renderer.setSize(window.innerWidth, window.innerHeight); 53 | renderer.toneMapping = THREE.ACESFilmicToneMapping; 54 | container.appendChild(renderer.domElement); 55 | 56 | const controls = new OrbitControls(camera, renderer.domElement); 57 | controls.addEventListener('change', render); // use if there is no animation loop 58 | controls.minDistance = 2; 59 | controls.maxDistance = 20; 60 | controls.target.set(0, 0, 0); 61 | controls.update(); 62 | 63 | window.addEventListener('resize', onWindowResize); 64 | } 65 | 66 | function onWindowResize() { 67 | camera.aspect = window.innerWidth / window.innerHeight; 68 | camera.updateProjectionMatrix(); 69 | 70 | renderer.setSize(window.innerWidth, window.innerHeight); 71 | 72 | render(); 73 | } 74 | 75 | // 76 | 77 | function render() { 78 | renderer.renderAsync(scene, camera); 79 | } 80 | -------------------------------------------------------------------------------- /exporter/__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.props import BoolProperty 3 | from bpy.types import PropertyGroup, Panel 4 | import logging 5 | 6 | bl_info = { 7 | "name": "glTF Curve Exporter Extension", 8 | "category": "Import-Export", 9 | "version": (1, 0, 2), 10 | "blender": (4, 2, 0), 11 | "location": "File > Export > glTF 2.0", 12 | "description": "Extension to export curve data in glTF files.", 13 | "tracker_url": "https://github.com/utsuboco/gltf-curve-exporter/issues/", 14 | "isDraft": False, 15 | "author": "Renaud Rohlinger", 16 | "support": "COMMUNITY", 17 | } 18 | 19 | # Set up logging 20 | logging.basicConfig(level=logging.DEBUG) 21 | logger = logging.getLogger(__name__) 22 | 23 | class CurveExtensionProperties(PropertyGroup): 24 | enabled: BoolProperty( 25 | name="Export Curves", 26 | description="Include curve data in the exported glTF file", 27 | default=True 28 | ) 29 | 30 | class GLTF_PT_CurveExtensionPanel(Panel): 31 | bl_space_type = 'FILE_BROWSER' 32 | bl_region_type = 'TOOL_PROPS' 33 | bl_label = "Curve Extension" 34 | bl_parent_id = "FILE_PT_operator" 35 | 36 | @classmethod 37 | def poll(cls, context): 38 | sfile = context.space_data 39 | operator = sfile.active_operator 40 | return operator.bl_idname == "EXPORT_SCENE_OT_gltf" 41 | 42 | def draw(self, context): 43 | layout = self.layout 44 | props = context.scene.curve_extension_properties 45 | layout.prop(props, "enabled") 46 | 47 | class glTF2ExportUserExtension: 48 | def __init__(self): 49 | from io_scene_gltf2.io.com.gltf2_io_extensions import Extension 50 | self.Extension = Extension 51 | self.properties = bpy.context.scene.curve_extension_properties 52 | 53 | def gather_node_hook(self, gltf2_object, blender_object, export_settings): 54 | logger.debug(f"gather_node_hook called for object: {blender_object.name}") 55 | 56 | def process_curve_object(obj): 57 | if self.properties.enabled and obj.type == 'CURVE': 58 | logger.info(f"Processing curve object: {obj.name}") 59 | curve_data = self.gather_curve_data(obj) 60 | if curve_data: 61 | if gltf2_object.extensions is None: 62 | gltf2_object.extensions = {} 63 | extension = self.Extension( 64 | name="UTSUBO_curve_extension", 65 | extension=curve_data, 66 | required=False 67 | ) 68 | gltf2_object.extensions["UTSUBO_curve_extension"] = extension 69 | logger.info(f"Added curve extension to object: {obj.name}") 70 | else: 71 | logger.warning(f"Failed to gather curve data for object: {obj.name}") 72 | else: 73 | logger.debug(f"Skipping object: {obj.name} (type: {obj.type})") 74 | 75 | try: 76 | if isinstance(blender_object, bpy.types.Object): 77 | process_curve_object(blender_object) 78 | elif isinstance(blender_object, bpy.types.Collection): 79 | logger.info(f"Processing collection: {blender_object.name}") 80 | for obj in blender_object.objects: 81 | process_curve_object(obj) 82 | else: 83 | logger.warning(f"Unexpected object type: {type(blender_object)}") 84 | except Exception as e: 85 | logger.error(f"Error processing object {blender_object.name}: {str(e)}") 86 | 87 | def gather_curve_data(self, blender_object): 88 | curve_data = blender_object.data 89 | splines_data = [] 90 | 91 | world_matrix = blender_object.matrix_world 92 | 93 | for spline in curve_data.splines: 94 | points = [] 95 | if spline.type == 'BEZIER': 96 | points = [ 97 | { 98 | "co": self.convert_vector_to_list(world_matrix @ p.co), 99 | "handle_left": self.convert_vector_to_list(world_matrix @ p.handle_left), 100 | "handle_right": self.convert_vector_to_list(world_matrix @ p.handle_right) 101 | } for p in spline.bezier_points 102 | ] 103 | elif spline.type == 'NURBS': 104 | points = [ 105 | { 106 | "co": self.convert_vector_to_list(world_matrix @ p.co), 107 | } for p in spline.points 108 | ] 109 | else: # 'POLY' 110 | points = [ 111 | {"co": self.convert_vector_to_list(world_matrix @ p.co)} for p in spline.points 112 | ] 113 | 114 | splines_data.append({ 115 | "type": spline.type, 116 | "points": points, 117 | "use_cyclic_u": spline.use_cyclic_u, 118 | "resolution_u": spline.resolution_u, 119 | "order_u": spline.order_u if spline.type == 'NURBS' else None 120 | }) 121 | 122 | return { 123 | "splines": splines_data, 124 | "dimensions": curve_data.dimensions 125 | } 126 | 127 | def convert_vector_to_list(self, vector): 128 | return [vector.x, vector.y, vector.z] 129 | 130 | def register(): 131 | logger.info("Registering Curve Exporter Extension") 132 | bpy.utils.register_class(CurveExtensionProperties) 133 | bpy.utils.register_class(GLTF_PT_CurveExtensionPanel) 134 | bpy.types.Scene.curve_extension_properties = bpy.props.PointerProperty(type=CurveExtensionProperties) 135 | 136 | def unregister(): 137 | logger.info("Unregistering Curve Exporter Extension") 138 | bpy.utils.unregister_class(GLTF_PT_CurveExtensionPanel) 139 | del bpy.types.Scene.curve_extension_properties 140 | bpy.utils.unregister_class(CurveExtensionProperties) 141 | 142 | if __name__ == "__main__": 143 | register() -------------------------------------------------------------------------------- /gltf-transform-curve.config.mjs: -------------------------------------------------------------------------------- 1 | import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; 2 | import { 3 | Extension, 4 | PropertyType, 5 | ExtensionProperty, 6 | } from '@gltf-transform/core'; 7 | 8 | const NAME = 'UTSUBO_curve_extension'; 9 | 10 | class CurveData extends ExtensionProperty { 11 | init() { 12 | this.extensionName = NAME; 13 | this.propertyType = 'CurveData'; 14 | this.parentTypes = [PropertyType.NODE]; 15 | this.splines = []; 16 | this.dimensions = '3D'; 17 | } 18 | 19 | getDefaults() { 20 | return { 21 | splines: [], 22 | dimensions: '3D', 23 | }; 24 | } 25 | 26 | getSplines() { 27 | return this.get('splines'); 28 | } 29 | 30 | setSplines(splines) { 31 | return this.set('splines', splines); 32 | } 33 | 34 | getDimensions() { 35 | return this.get('dimensions'); 36 | } 37 | 38 | setDimensions(dimensions) { 39 | return this.set('dimensions', dimensions); 40 | } 41 | } 42 | 43 | class CurveExtension extends Extension { 44 | constructor(doc) { 45 | super(doc); 46 | this.extensionName = NAME; 47 | this.propertyType = CurveData; 48 | } 49 | 50 | static get EXTENSION_NAME() { 51 | return NAME; 52 | } 53 | 54 | createCurveData() { 55 | return new CurveData(this.document.getGraph()); 56 | } 57 | 58 | read(context) { 59 | const jsonDoc = context.jsonDoc; 60 | 61 | (jsonDoc.json.nodes || []).forEach((nodeDef, nodeIndex) => { 62 | if (nodeDef.extensions && nodeDef.extensions[NAME]) { 63 | const curveDataDef = nodeDef.extensions[NAME]; 64 | const curveData = this.createCurveData() 65 | .setSplines(curveDataDef.splines) 66 | .setDimensions(curveDataDef.dimensions); 67 | 68 | console.log(curveData); 69 | const node = context.nodes[nodeIndex]; 70 | node.setExtension(NAME, curveData); 71 | } 72 | }); 73 | 74 | return this; 75 | } 76 | 77 | write(context) { 78 | const jsonDoc = context.jsonDoc; 79 | 80 | this.document 81 | .getRoot() 82 | .listNodes() 83 | .forEach((node) => { 84 | const curveData = node.getExtension(NAME); 85 | if (curveData) { 86 | const nodeIndex = context.nodeIndexMap.get(node); 87 | const nodeDef = jsonDoc.json.nodes[nodeIndex]; 88 | nodeDef.extensions = nodeDef.extensions || {}; 89 | nodeDef.extensions[NAME] = { 90 | splines: curveData.getSplines(), 91 | dimensions: curveData.getDimensions(), 92 | }; 93 | } 94 | }); 95 | 96 | // Ensure the extension is listed in extensionsUsed 97 | if (!jsonDoc.json.extensionsUsed) { 98 | jsonDoc.json.extensionsUsed = []; 99 | } 100 | if (!jsonDoc.json.extensionsUsed.includes(NAME)) { 101 | jsonDoc.json.extensionsUsed.push(NAME); 102 | } 103 | 104 | return this; 105 | } 106 | } 107 | 108 | export default { extensions: [...ALL_EXTENSIONS, CurveExtension] }; 109 | -------------------------------------------------------------------------------- /gltf_curve_exporter.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utsuboco/gltf_curve_exporter/5b00ba9385f9e5d9e9b6ab9b93c75cf5f035d977/gltf_curve_exporter.zip -------------------------------------------------------------------------------- /loader/GLTFCurveExtension.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { NURBSCurve } from 'three/examples/jsm/curves/NURBSCurve.js'; 3 | 4 | let _index = 0; 5 | class GLTFCurveExtension { 6 | constructor(parser) { 7 | this.parser = parser; 8 | this.name = 'UTSUBO_curve_extension'; 9 | } 10 | 11 | afterRoot(result) { 12 | const parser = this.parser; 13 | const json = parser.json; 14 | 15 | if (json.nodes) { 16 | // Wait for all nodes to be loaded before processing curves 17 | return parser.getDependencies('node').then((nodes) => { 18 | return this.createCurves(json.nodes, result, nodes); 19 | }); 20 | } 21 | 22 | return null; 23 | } 24 | 25 | createCurves(nodeDefs, result, loadedNodes) { 26 | const pending = []; 27 | 28 | for (let i = 0; i < nodeDefs.length; i++) { 29 | const nodeDef = nodeDefs[i]; 30 | if (nodeDef.extensions && nodeDef.extensions[this.name]) { 31 | pending.push(this.createCurve(nodeDef, result, i, loadedNodes[i])); 32 | } 33 | } 34 | 35 | return Promise.all(pending); 36 | } 37 | 38 | createCurve(nodeDef, result, nodeIndex, loadedNode) { 39 | const curveData = nodeDef.extensions[this.name]; 40 | const curves = []; 41 | 42 | curveData.splines.forEach((spline) => { 43 | let curve; 44 | 45 | if (spline.type === 'BEZIER') { 46 | const points = spline.points.map((point) => 47 | this.convertBlenderToThreeCoordinates(point.co) 48 | ); 49 | const handles1 = spline.points.map((point) => 50 | this.convertBlenderToThreeCoordinates(point.handle_left) 51 | ); 52 | const handles2 = spline.points.map((point) => 53 | this.convertBlenderToThreeCoordinates(point.handle_right) 54 | ); 55 | 56 | if (points.length === 2) { 57 | curve = new THREE.CubicBezierCurve3( 58 | points[0], 59 | handles2[0], 60 | handles1[1], 61 | points[1] 62 | ); 63 | } else { 64 | curve = new THREE.CurvePath(); 65 | for (let i = 0; i < points.length - 1; i++) { 66 | const bezierCurve = new THREE.CubicBezierCurve3( 67 | points[i], 68 | handles2[i], 69 | handles1[i + 1], 70 | points[i + 1] 71 | ); 72 | curve.curves.push(bezierCurve); 73 | } 74 | 75 | if (spline.use_cyclic_u) { 76 | const lastIndex = points.length - 1; 77 | const bezierCurve = new THREE.CubicBezierCurve3( 78 | points[lastIndex], 79 | handles2[lastIndex], 80 | handles1[0], 81 | points[0] 82 | ); 83 | curve.curves.push(bezierCurve); 84 | curve.curves[0].v0 = points[0].clone(); // Ensure the first point is connected 85 | } 86 | } 87 | } else if (spline.type === 'NURBS') { 88 | curve = this.createNURBSCurvePath(spline); 89 | } else { 90 | // Poly curve (linear) 91 | const points = spline.points.map((point) => 92 | this.convertBlenderToThreeCoordinates(point.co) 93 | ); 94 | curve = new THREE.CatmullRomCurve3(points, spline.use_cyclic_u); 95 | } 96 | 97 | if (curve) { 98 | curves.push(curve); 99 | } 100 | }); 101 | 102 | // Create a single CurvePath if there are multiple splines 103 | let finalCurve = curves[0]; 104 | if (curves.length > 1) { 105 | finalCurve = new THREE.CurvePath(); 106 | curves.forEach((c) => finalCurve.add(c)); 107 | } 108 | 109 | // Create a visible line for the curve 110 | const points = finalCurve.getPoints( 111 | curveData.splines[0].resolution_u * 10 || 100 112 | ); 113 | const geometry = new THREE.BufferGeometry().setFromPoints(points); 114 | const material = new THREE.LineBasicMaterial({ color: 0xffffff }); 115 | const curveObject = new THREE.Line(geometry, material); 116 | curveObject.name = nodeDef.name || spline.type + '_' + _index++; 117 | 118 | this.applyNodeTransform(curveObject, nodeDef); 119 | 120 | // Store the curve data on the object for future use 121 | curveObject.userData.curve = finalCurve; 122 | 123 | // Set up associations as in the official GLTFLoader 124 | if (!this.parser.associations.has(curveObject)) { 125 | this.parser.associations.set(curveObject, {}); 126 | } 127 | this.parser.associations.get(curveObject).nodes = nodeIndex; 128 | 129 | // Use loadedNode instead of finding it in the scene 130 | return Promise.resolve(curveObject).then((curveObject) => { 131 | this.replaceCurveInScene(result, curveObject, loadedNode); 132 | return curveObject; 133 | }); 134 | } 135 | 136 | convertBlenderToThreeCoordinates(coord) { 137 | return new THREE.Vector3(coord[0], coord[2], -coord[1]); 138 | } 139 | 140 | applyNodeTransform(object, nodeDef) { 141 | if (nodeDef.matrix !== undefined) { 142 | const matrix = new THREE.Matrix4(); 143 | matrix.fromArray(nodeDef.matrix); 144 | object.applyMatrix4(matrix); 145 | } else { 146 | if (nodeDef.translation !== undefined) { 147 | object.position.fromArray(nodeDef.translation); 148 | } 149 | if (nodeDef.rotation !== undefined) { 150 | const rotation = new THREE.Quaternion().fromArray(nodeDef.rotation); 151 | const euler = new THREE.Euler().setFromQuaternion(rotation, 'XYZ'); 152 | euler.x = -euler.x; 153 | euler.y = -euler.y; 154 | object.rotation.copy(euler); 155 | } 156 | if (nodeDef.scale !== undefined) { 157 | object.scale.fromArray(nodeDef.scale); 158 | } 159 | } 160 | } 161 | 162 | replaceCurveInScene(result, curveObject, originalNode) { 163 | if (originalNode && originalNode.parent) { 164 | const parent = originalNode.parent; 165 | const index = parent.children.indexOf(originalNode); 166 | 167 | if (index !== -1) { 168 | // Transfer children 169 | while (originalNode.children.length > 0) { 170 | curveObject.add(originalNode.children[0]); 171 | } 172 | 173 | // Copy transformation 174 | curveObject.position.copy(originalNode.position); 175 | curveObject.quaternion.copy(originalNode.quaternion); 176 | curveObject.scale.copy(originalNode.scale); 177 | 178 | // Replace in parent's children array 179 | parent.children[index] = curveObject; 180 | curveObject.parent = parent; 181 | 182 | // Merge userData 183 | curveObject.userData = { 184 | ...originalNode.userData, 185 | ...curveObject.userData, 186 | }; 187 | 188 | // Clean up original node 189 | originalNode.parent = null; 190 | } else { 191 | console.warn(`Original node not found in parent's children.`); 192 | } 193 | } else { 194 | console.warn( 195 | `Original node or its parent not found. Adding curve to scene root.` 196 | ); 197 | result.scene.add(curveObject); 198 | } 199 | } 200 | 201 | createNURBSCurvePath(nurbsData) { 202 | const degree = nurbsData.order_u - 1; 203 | const knots = 204 | nurbsData.knots || 205 | this.generateKnots( 206 | nurbsData.points.length, 207 | degree, 208 | nurbsData.use_cyclic_u 209 | ); 210 | 211 | const controlPoints = nurbsData.points.map((point) => { 212 | const threePoint = this.convertBlenderToThreeCoordinates(point.co); 213 | return new THREE.Vector4( 214 | threePoint.x, 215 | threePoint.y, 216 | threePoint.z, 217 | point.w || 1 218 | ); 219 | }); 220 | 221 | let startKnot, endKnot; 222 | if (nurbsData.use_cyclic_u) { 223 | startKnot = degree; 224 | endKnot = knots.length - degree - 1; 225 | } else { 226 | startKnot = 0; 227 | endKnot = knots.length - 1; 228 | } 229 | 230 | const nurbsCurve = new NURBSCurve( 231 | degree, 232 | knots, 233 | controlPoints, 234 | startKnot, 235 | endKnot 236 | ); 237 | 238 | // Convert NURBS curve to points 239 | const numPoints = Math.max(200, nurbsData.resolution_u * 10); 240 | const points = nurbsCurve.getPoints(numPoints); 241 | 242 | // Create CurvePath 243 | const curvePath = new THREE.CurvePath(); 244 | curvePath.add( 245 | new THREE.CatmullRomCurve3(points, nurbsData.use_cyclic_u, 'centripetal') 246 | ); 247 | 248 | return curvePath; 249 | } 250 | 251 | // Helper function to generate knots 252 | generateKnots(numPoints, degree, cyclic) { 253 | const order = degree + 1; 254 | let knots = []; 255 | 256 | if (cyclic) { 257 | // For cyclic curves, create a periodic knot vector 258 | const numKnots = numPoints + order; 259 | for (let i = 0; i < numKnots; i++) { 260 | knots.push(i); 261 | } 262 | } else { 263 | for (let i = 0; i < order; i++) { 264 | knots.push(0); 265 | } 266 | for (let i = 1; i <= numPoints - order; i++) { 267 | knots.push(i); 268 | } 269 | for (let i = 0; i < order; i++) { 270 | knots.push(numPoints - order + 1); 271 | } 272 | } 273 | 274 | // Normalize the knot vector 275 | const last = knots[knots.length - 1]; 276 | return knots.map((k) => k / last); 277 | } 278 | } 279 | 280 | export { GLTFCurveExtension }; 281 | --------------------------------------------------------------------------------