├── .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 |
--------------------------------------------------------------------------------