signalk-chiplog 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -1
- package/README.md +95 -7
- package/package.json +4 -2
- package/public/app.css +50 -0
- package/public/js/animation/boat-model.mjs +370 -0
- package/public/js/animation/boat-parts.mjs +57 -0
- package/public/js/animation/boat-pose.mjs +119 -0
- package/public/js/animation/boat-store.mjs +71 -0
- package/public/js/animation/camera3d.mjs +258 -0
- package/public/js/animation/motion.mjs +201 -0
- package/public/js/animation/renderer.mjs +14 -8
- package/public/js/animation/renderer3d.mjs +593 -0
- package/public/js/animation/storyboard.mjs +210 -20
- package/public/js/animation/webgl.mjs +27 -0
- package/public/js/components/AnimationExport.mjs +8 -1
- package/public/js/components/AnimationView.mjs +280 -5
- package/public/js/components/PassageView.mjs +2 -6
- package/public/js/context.mjs +5 -5
- package/public/js/days.mjs +33 -0
- package/public/js/i18n.mjs +61 -0
- package/public/vendor/three-LICENSE +21 -0
- package/public/vendor/three.min.mjs +4220 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ All notable changes to Chiplog are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.6.0] - 2026-09-20
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **The animation's date range is kept in the address.** Picking a period updates the page's URL, so reloading the page
|
|
14
|
+
or sharing the link keeps the same dates.
|
|
15
|
+
- **The boat no longer disappears when a passage leaves from where the last one arrived.** There is then no camera move
|
|
16
|
+
at all: the boat stays in the frame through the short rest and the next passage starts from it. It is still hidden
|
|
17
|
+
while the camera flies between two different places.
|
|
18
|
+
- **The animation opens and closes on the whole navigation.** It starts on a view of everything that will be sailed,
|
|
19
|
+
zooms down to the first position in two seconds, and after the last arrival pulls back out to the whole navigation in
|
|
20
|
+
two seconds, the boat not drawn during either move: it appears where the sailing begins. The rest at the end of each
|
|
21
|
+
passage is shortened to a beat (0.3 s at ×1), so the boat no longer stands still for a second or more between
|
|
22
|
+
passages.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **A 3D view of the animation.** The Animation page has a new _View_ switch: the same film, at the map's own scale and
|
|
27
|
+
with north at the top, seen by a camera tilted down onto a 3D sailboat, with the map laid flat under it. The boat
|
|
28
|
+
follows the general direction of the track rather than every sampled heading, yet turns with it at a tack or a
|
|
29
|
+
headland, heels and trims its sails to the wind, rides the waves with a pitch and a light roll, and you can frame it
|
|
30
|
+
closer or wider than the map and choose the boat's size. Load your own boat as a `.glb` file and it is used instead —
|
|
31
|
+
with its sails trimmed to the wind too, if their nodes are named `Mainsail` and `Jib` (the README has a guide to
|
|
32
|
+
preparing a model) — kept in that browser. **Export the video** films whichever view is showing (`-3d` in the file
|
|
33
|
+
name). The 3D view needs WebGL 2 and is loaded only when asked for; without it, the map view is used. It adds three.js
|
|
34
|
+
to the vendored libraries (about 600 KB, MIT).
|
|
35
|
+
|
|
9
36
|
## [2.5.1] - 2026-09-19
|
|
10
37
|
|
|
11
38
|
### Fixed
|
|
@@ -325,7 +352,8 @@ First release.
|
|
|
325
352
|
- REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
|
|
326
353
|
- Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
|
|
327
354
|
|
|
328
|
-
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.
|
|
355
|
+
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.6.0...HEAD
|
|
356
|
+
[2.6.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.5.1...v2.6.0
|
|
329
357
|
[2.5.1]: https://github.com/ricard33/signalk-chiplog/compare/v2.5.0...v2.5.1
|
|
330
358
|
[2.5.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.4.0...v2.5.0
|
|
331
359
|
[2.4.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.1...v2.4.0
|
package/README.md
CHANGED
|
@@ -227,13 +227,19 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
|
|
|
227
227
|
distance and time, the flags of the countries visited, and the top 5 passages by duration, distance, average speed,
|
|
228
228
|
top speed and wind. Countries come from the place names, so they need geocoding enabled and the boat online now and
|
|
229
229
|
then; places named before this existed fill in by themselves.
|
|
230
|
-
- **Animation** — pick a period
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
- **Animation** — pick a period (kept in the page's address, so a reload or a shared link keeps it) and every passage in
|
|
231
|
+
it replays on the map, one after another, the port time skipped. The film opens on the whole navigation and zooms down
|
|
232
|
+
to the first position in two seconds, and pulls back out to the whole navigation at the end. The map follows the boat
|
|
233
|
+
at a scale chosen for each passage — a short hop kept readable rather than magnified, a long crossing allowed a wider
|
|
234
|
+
view but never so wide the boat crawls across empty water — while a bubble shows the speed, the distance covered since
|
|
235
|
+
the start and the date. Play, pause and a slider over the animation's own time, at ×0,5, ×1, ×2 or ×4. **Export MP4**
|
|
236
|
+
saves it as a video in one of five shapes (Mobile 9:16, Portrait 3:4, Square 1:1, Landscape 4:3, Widescreen 16:9).
|
|
237
|
+
Everything happens in the browser: nothing is rendered or encoded on the Signal K server, and the map tiles are the
|
|
238
|
+
only thing downloaded. **View → 3D** shows the same film, at the map's own scale and with north still at the top, as a
|
|
239
|
+
camera tilted down onto a 3D sailboat that heels and trims its sails to the wind and rides the waves, with the map
|
|
240
|
+
laid flat under it; frame it closer or wider than the map and pick the boat's size if you like, and load your own boat
|
|
241
|
+
as a `.glb` file — sails included — if you would rather see it (see
|
|
242
|
+
[Your own boat](#your-own-boat-in-the-3d-animation-)). The MP4 export then films the 3D view.
|
|
237
243
|
- **Export** — download the whole logbook or a date range as a PDF logbook to print, JSON, CSV or GPX, and write the
|
|
238
244
|
abandon-ship copy to the USB drive now (admin). The PDF is written in the webapp's language and the device's time
|
|
239
245
|
zone.
|
|
@@ -246,6 +252,85 @@ shortcuts beside it; where any date is allowed, the calendar has an _Any date_ b
|
|
|
246
252
|
|
|
247
253
|
**Helm entry** in the top bar opens the tablet entry app.
|
|
248
254
|
|
|
255
|
+
### Your own boat in the 3D animation ⛵
|
|
256
|
+
|
|
257
|
+
The 3D view draws a generic sailboat. To see yours instead, use **Boat → Use my own boat…** under the animation and pick
|
|
258
|
+
a `.glb` file. The model is kept in **that browser only** (it is never sent to the logbook or the Signal K server), so
|
|
259
|
+
it has to be loaded again on another device; **Use the default boat** puts the generic one back.
|
|
260
|
+
|
|
261
|
+
**The file**
|
|
262
|
+
|
|
263
|
+
| | |
|
|
264
|
+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
265
|
+
| Format | Binary glTF 2.0 (`.glb`), one self-contained file — textures embedded |
|
|
266
|
+
| Size | 30 MB at most (a few MB is plenty: the boat is small on the film) |
|
|
267
|
+
| Not supported | Draco, Meshopt or KTX2 compression. Animations, cameras and lights inside the file are ignored — the view lights the scene itself |
|
|
268
|
+
| Materials | Ordinary glTF materials. Make the sails **double-sided**, or one face vanishes as they swing |
|
|
269
|
+
|
|
270
|
+
**Orientation, size and waterline**
|
|
271
|
+
|
|
272
|
+
- **Y is up and the bow points towards +Z** — the glTF convention. Nothing else says which end is the front, so a boat
|
|
273
|
+
that sails backwards needs a half turn about the vertical axis before it is exported.
|
|
274
|
+
- **Any unit.** The model is scaled so that its longest horizontal extent — length, or width if that is larger,
|
|
275
|
+
including a boom or a bowsprit — is one boat length, and centred in plan. A stray helper object far from the boat (a
|
|
276
|
+
ground plane, a light) enlarges that box and shrinks the boat: delete them before exporting.
|
|
277
|
+
- **The lowest point of the model is taken as the bottom of the keel** and placed a twentieth of the boat's length under
|
|
278
|
+
the waterline, everything else above. The sea is drawn behind the boat and never cuts into it, so the hull is always
|
|
279
|
+
whole.
|
|
280
|
+
- **The whole boat turns, heels, pitches and rides the waves** with no work on your part. Its size on screen is set by
|
|
281
|
+
the _Boat size_ buttons, not by the model.
|
|
282
|
+
|
|
283
|
+
**Sails that move to the wind**
|
|
284
|
+
|
|
285
|
+
Name the sail nodes and the view trims them like the default boat's: let out as the wind comes aft, and on the side away
|
|
286
|
+
from it.
|
|
287
|
+
|
|
288
|
+
| Sail | Names recognised | Origin of the node | Turns by |
|
|
289
|
+
| -------- | ------------------------------------------------------------------- | ------------------------------------ | ------------------- |
|
|
290
|
+
| Mainsail | `Mainsail`, `Main`, `Sail_Main`, `GrandVoile`, `GV` | On the mast, at the foot of the sail | The full sail angle |
|
|
291
|
+
| Headsail | `Jib`, `Genoa`, `Headsail`, `Foresail`, `Sail_Jib`, `Foc`, `Génois` | On the forestay, at the tack | 0.8 of it |
|
|
292
|
+
|
|
293
|
+
Case, spaces, punctuation and the number a modelling tool adds to a copy (`Mainsail.001`) do not matter.
|
|
294
|
+
|
|
295
|
+
- **The sail is rotated about the vertical axis through the origin of its node.** Put that origin where the sail should
|
|
296
|
+
pivot — the mast, or the bow fitting for a jib — not at the middle of the cloth, or it will swing sideways.
|
|
297
|
+
- **Model it at rest on the centreline**, the boom pointing aft (−Z). The view adds the trim angle to whatever rotation
|
|
298
|
+
the node already has.
|
|
299
|
+
- **The angle** is about half the apparent wind angle, at least 4° and at most 88°, smoothed like the boat's heading.
|
|
300
|
+
The wind on the starboard side puts the sail to port, and the other way round. Where the track has no wind reading the
|
|
301
|
+
sails stay on the centreline.
|
|
302
|
+
- **Put the boom, the sail and its fittings inside the sail node** so they turn together. Only the outermost node with a
|
|
303
|
+
recognised name is turned; anything nested in it goes along.
|
|
304
|
+
- **The cloth is rigid.** It does not billow, and a camber does not swap sides with the tack: model it flat or
|
|
305
|
+
symmetrical.
|
|
306
|
+
- **Nothing else is animated** — no flag, rudder or propeller.
|
|
307
|
+
|
|
308
|
+
**From Blender**
|
|
309
|
+
|
|
310
|
+
1. Model the boat with its bow towards **−Y** and Z up — Blender's own front — so it comes out towards +Z with Y up.
|
|
311
|
+
2. Name the sail objects `Mainsail` and `Jib`.
|
|
312
|
+
3. For each sail, put the 3D cursor on the mast foot (or the tack) and use **Object ▸ Set Origin ▸ Origin to 3D
|
|
313
|
+
Cursor**. Apply rotation and scale (**Ctrl+A**).
|
|
314
|
+
4. Set the sail material to double-sided (**Backface Culling** off).
|
|
315
|
+
5. **File ▸ Export ▸ glTF 2.0**, format **glTF Binary (.glb)**, **+Y Up** ticked, compression off; lights and cameras
|
|
316
|
+
need not be included.
|
|
317
|
+
6. Load the file in the animation. The line under the boat says which sails were found — _Sails trimmed to the wind:
|
|
318
|
+
mainsail, jib_ — or _No sail recognised_ if a name is off.
|
|
319
|
+
|
|
320
|
+
**When something is wrong**
|
|
321
|
+
|
|
322
|
+
| What you see | Why, and what to do |
|
|
323
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
324
|
+
| The boat sails backwards | The bow points towards −Z. Turn the model 180° about the vertical axis and export again |
|
|
325
|
+
| The boat lies on its side | Exported with Z up. Export with **+Y Up** |
|
|
326
|
+
| The boat is tiny | Something far from it is in the file — a ground plane, a light. Remove it |
|
|
327
|
+
| _No sail recognised_ | The nodes are not named as in the table, or a wrapper node above them has a sail name and hides them |
|
|
328
|
+
| The sails do not move | The track has no wind readings (there is no apparent wind angle), or the sail nodes are called something else |
|
|
329
|
+
| A sail swings about an odd point | Its origin is not on the mast or the tack |
|
|
330
|
+
| One face of a sail disappears | The material is single-sided |
|
|
331
|
+
| _That file could not be read_ | It is not a `.glb`, or it uses Draco, Meshopt or KTX2 compression |
|
|
332
|
+
| The model is gone next time | The browser's site data was cleared, or it is another browser or device. Load it again |
|
|
333
|
+
|
|
249
334
|
## The tablet entry app 📱
|
|
250
335
|
|
|
251
336
|
Open `/signalk-chiplog/entry/` on the tablet, or follow **Helm entry** from the logbook. For an app-like, full-screen
|
|
@@ -565,6 +650,9 @@ between attempts; nothing is lost, it just takes longer to resolve.
|
|
|
565
650
|
- **Offline charts** are not provided; the animation draws the tracks on a blank sea when there is no connection.
|
|
566
651
|
- **The MP4 export needs a browser with WebCodecs** — Chrome, Edge, Safari 17 or Firefox 130 and later. Without it the
|
|
567
652
|
animation still plays on screen, and the export button is simply not offered.
|
|
653
|
+
- **The 3D view needs WebGL 2.** Without it the switch is greyed out and the map view is used. Heel, pitch and sail trim
|
|
654
|
+
are estimated from the wind — the logbook records none of them — and a boat model you load stays in that browser only,
|
|
655
|
+
so it has to be loaded again on another device.
|
|
568
656
|
- **A long animation takes a while to export**: every frame waits for its map before it is drawn, so the video is the
|
|
569
657
|
same whatever the connection was doing, but a long range means a lot of tiles. Turning the seamarks off halves them.
|
|
570
658
|
- **One vessel per Signal K server**, and no per-crew-member authorship.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-chiplog",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@eslint/js": "^10.0.1",
|
|
68
|
+
"esbuild": "0.28.2",
|
|
68
69
|
"eslint": "^10.10.0",
|
|
69
70
|
"eslint-config-prettier": "^10.1.8",
|
|
70
71
|
"express": "^4.22.2",
|
|
@@ -75,6 +76,7 @@
|
|
|
75
76
|
"lint-staged": "^17.5.1",
|
|
76
77
|
"mediabunny": "1.58.0",
|
|
77
78
|
"preact": "^10.29.8",
|
|
78
|
-
"prettier": "^3.9.6"
|
|
79
|
+
"prettier": "^3.9.6",
|
|
80
|
+
"three": "0.186.0"
|
|
79
81
|
}
|
|
80
82
|
}
|
package/public/app.css
CHANGED
|
@@ -1171,6 +1171,56 @@ button.preset-on {
|
|
|
1171
1171
|
color: var(--accent-contrast);
|
|
1172
1172
|
}
|
|
1173
1173
|
|
|
1174
|
+
.animation-modes {
|
|
1175
|
+
display: flex;
|
|
1176
|
+
flex-wrap: wrap;
|
|
1177
|
+
align-items: center;
|
|
1178
|
+
gap: 0.6rem 1.2rem;
|
|
1179
|
+
margin-bottom: 0.75rem;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
.animation-modes-group {
|
|
1183
|
+
display: flex;
|
|
1184
|
+
align-items: center;
|
|
1185
|
+
gap: 0.3rem;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
.animation-modes-label {
|
|
1189
|
+
color: var(--muted);
|
|
1190
|
+
margin-right: 0.2rem;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
.animation-mode {
|
|
1194
|
+
padding: 0.35rem 0.6rem;
|
|
1195
|
+
background: none;
|
|
1196
|
+
color: var(--accent);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
.animation-mode[aria-pressed='true'] {
|
|
1200
|
+
background: var(--accent);
|
|
1201
|
+
color: var(--accent-contrast);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
.animation-mode:disabled {
|
|
1205
|
+
opacity: 0.5;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
.animation-boat {
|
|
1209
|
+
display: flex;
|
|
1210
|
+
flex-wrap: wrap;
|
|
1211
|
+
align-items: center;
|
|
1212
|
+
gap: 0.5rem 0.8rem;
|
|
1213
|
+
margin-top: 0.75rem;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
.animation-boat-file {
|
|
1217
|
+
position: absolute;
|
|
1218
|
+
width: 1px;
|
|
1219
|
+
height: 1px;
|
|
1220
|
+
opacity: 0;
|
|
1221
|
+
overflow: hidden;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1174
1224
|
.animation-facts {
|
|
1175
1225
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
1176
1226
|
margin: 0.75rem 0 0;
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// The boat of the 3D view: a sailboat built in code, or the crew's own model.
|
|
2
|
+
//
|
|
3
|
+
// Both are one unit long, bow towards +z, y up, with the waterline at y = 0, so
|
|
4
|
+
// the renderer scales either to whatever the camera needs. Nothing is a file:
|
|
5
|
+
// the default boat is generated, so there is no asset to ship or to credit.
|
|
6
|
+
|
|
7
|
+
import * as THREE from '../../vendor/three.min.mjs';
|
|
8
|
+
import { findSails, SAIL_ROLES } from './boat-parts.mjs';
|
|
9
|
+
|
|
10
|
+
const HULL_STATIONS = [
|
|
11
|
+
// u: along the hull from the stern; half beam; deck height above the water.
|
|
12
|
+
{ u: 0, beam: 0.108, deck: 0.078 },
|
|
13
|
+
{ u: 0.12, beam: 0.136, deck: 0.072 },
|
|
14
|
+
{ u: 0.3, beam: 0.152, deck: 0.068 },
|
|
15
|
+
{ u: 0.5, beam: 0.153, deck: 0.07 },
|
|
16
|
+
{ u: 0.68, beam: 0.132, deck: 0.078 },
|
|
17
|
+
{ u: 0.84, beam: 0.084, deck: 0.09 },
|
|
18
|
+
{ u: 0.95, beam: 0.032, deck: 0.102 },
|
|
19
|
+
{ u: 1, beam: 0.002, deck: 0.108 }
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// One half of a cross-section, from the deck edge down to the keel, as fractions
|
|
23
|
+
// of the half beam and of the draught.
|
|
24
|
+
const SECTION = [
|
|
25
|
+
{ x: 1, y: 'deck' },
|
|
26
|
+
{ x: 0.99, y: 0.25 },
|
|
27
|
+
{ x: 0.93, y: 0 },
|
|
28
|
+
{ x: 0.78, y: -0.42 },
|
|
29
|
+
{ x: 0.52, y: -0.78 },
|
|
30
|
+
{ x: 0.2, y: -0.97 }
|
|
31
|
+
];
|
|
32
|
+
const DRAUGHT = 0.05;
|
|
33
|
+
|
|
34
|
+
const HULL_LENGTH = 1;
|
|
35
|
+
const COLOURS = {
|
|
36
|
+
topsides: new THREE.Color('#f4f1ea'),
|
|
37
|
+
stripe: new THREE.Color('#1d4ed8'),
|
|
38
|
+
bottom: new THREE.Color('#7a1f1f'),
|
|
39
|
+
deck: new THREE.Color('#c9a26b'),
|
|
40
|
+
cabin: new THREE.Color('#f4f1ea'),
|
|
41
|
+
glass: new THREE.Color('#1b2733'),
|
|
42
|
+
metal: new THREE.Color('#9aa5b1'),
|
|
43
|
+
sail: new THREE.Color('#fbfaf5')
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function interpolateStation(u, key) {
|
|
47
|
+
for (let i = 0; i < HULL_STATIONS.length - 1; i += 1) {
|
|
48
|
+
const a = HULL_STATIONS[i];
|
|
49
|
+
const b = HULL_STATIONS[i + 1];
|
|
50
|
+
if (u <= b.u) {
|
|
51
|
+
const t = (u - a.u) / (b.u - a.u);
|
|
52
|
+
const eased = t * t * (3 - 2 * t);
|
|
53
|
+
return a[key] + (b[key] - a[key]) * eased;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return HULL_STATIONS[HULL_STATIONS.length - 1][key];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hullColour(y, deck) {
|
|
60
|
+
if (y < -0.004) {
|
|
61
|
+
return COLOURS.bottom;
|
|
62
|
+
}
|
|
63
|
+
// A blue stripe just under the rail, white above and below it.
|
|
64
|
+
return y > deck - 0.02 && y < deck - 0.008 ? COLOURS.stripe : COLOURS.topsides;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildHull() {
|
|
68
|
+
const stations = 34;
|
|
69
|
+
const positions = [];
|
|
70
|
+
const colours = [];
|
|
71
|
+
const indices = [];
|
|
72
|
+
let ringSize = 0;
|
|
73
|
+
|
|
74
|
+
for (let s = 0; s <= stations; s += 1) {
|
|
75
|
+
const u = s / stations;
|
|
76
|
+
const z = (u - 0.5) * HULL_LENGTH;
|
|
77
|
+
const beam = interpolateStation(u, 'beam');
|
|
78
|
+
const deck = interpolateStation(u, 'deck');
|
|
79
|
+
// Starboard is -x. The ring runs down the starboard side, up the port one,
|
|
80
|
+
// and closes across the deck.
|
|
81
|
+
const half = SECTION.map((point) => ({
|
|
82
|
+
x: point.x * beam,
|
|
83
|
+
y: point.y === 'deck' ? deck : point.y * (point.y < 0 ? DRAUGHT : deck)
|
|
84
|
+
}));
|
|
85
|
+
const ring = [
|
|
86
|
+
...half.map((point) => ({ x: -point.x, y: point.y })),
|
|
87
|
+
...half
|
|
88
|
+
.slice()
|
|
89
|
+
.reverse()
|
|
90
|
+
.map((point) => ({ x: point.x, y: point.y }))
|
|
91
|
+
];
|
|
92
|
+
ringSize = ring.length;
|
|
93
|
+
for (const point of ring) {
|
|
94
|
+
positions.push(point.x, point.y, z);
|
|
95
|
+
const colour = hullColour(point.y, deck);
|
|
96
|
+
colours.push(colour.r, colour.g, colour.b);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (let s = 0; s < stations; s += 1) {
|
|
100
|
+
for (let i = 0; i < ringSize; i += 1) {
|
|
101
|
+
const a = s * ringSize + i;
|
|
102
|
+
const b = s * ringSize + ((i + 1) % ringSize);
|
|
103
|
+
const c = (s + 1) * ringSize + i;
|
|
104
|
+
const d = (s + 1) * ringSize + ((i + 1) % ringSize);
|
|
105
|
+
indices.push(a, c, b, b, c, d);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const geometry = new THREE.BufferGeometry();
|
|
109
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
110
|
+
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colours, 3));
|
|
111
|
+
geometry.setIndex(indices);
|
|
112
|
+
geometry.computeVertexNormals();
|
|
113
|
+
return geometry;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The deck follows the sheer of the hull, a hair above it.
|
|
117
|
+
function buildDeck() {
|
|
118
|
+
const stations = 34;
|
|
119
|
+
const positions = [];
|
|
120
|
+
const indices = [];
|
|
121
|
+
for (let s = 0; s <= stations; s += 1) {
|
|
122
|
+
const u = s / stations;
|
|
123
|
+
const z = (u - 0.5) * HULL_LENGTH;
|
|
124
|
+
const beam = interpolateStation(u, 'beam') * 0.94;
|
|
125
|
+
const deck = interpolateStation(u, 'deck') + 0.0015;
|
|
126
|
+
positions.push(-beam, deck, z, beam, deck, z);
|
|
127
|
+
}
|
|
128
|
+
for (let s = 0; s < stations; s += 1) {
|
|
129
|
+
const a = s * 2;
|
|
130
|
+
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
|
|
131
|
+
}
|
|
132
|
+
const geometry = new THREE.BufferGeometry();
|
|
133
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
134
|
+
geometry.setIndex(indices);
|
|
135
|
+
geometry.computeVertexNormals();
|
|
136
|
+
return geometry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function material(colour, options = {}) {
|
|
140
|
+
return new THREE.MeshStandardMaterial({
|
|
141
|
+
color: colour,
|
|
142
|
+
roughness: 0.6,
|
|
143
|
+
metalness: 0,
|
|
144
|
+
...options
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function mesh(geometry, colour, options) {
|
|
149
|
+
const result = new THREE.Mesh(geometry, material(colour, options));
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const MAST_Z = 0.1;
|
|
154
|
+
const DECK_AT_MAST = 0.082;
|
|
155
|
+
const MAST_HEIGHT = 0.62;
|
|
156
|
+
const BOOM_LENGTH = 0.3;
|
|
157
|
+
const FORESTAY_Z = 0.44;
|
|
158
|
+
const FORESTAY_DECK = 0.1;
|
|
159
|
+
|
|
160
|
+
// A triangle of sail as a small grid, so it can belly. Vertices are in the
|
|
161
|
+
// sail's own plane; `camber` moves the middle out of it, to leeward.
|
|
162
|
+
function sailGeometry(corners, camber, rows = 6) {
|
|
163
|
+
const [tack, head, clew] = corners;
|
|
164
|
+
const positions = [];
|
|
165
|
+
const indices = [];
|
|
166
|
+
for (let r = 0; r <= rows; r += 1) {
|
|
167
|
+
for (let c = 0; c <= rows - r; c += 1) {
|
|
168
|
+
// Barycentric: r along the leech from the clew, c along the foot.
|
|
169
|
+
const a = r / rows;
|
|
170
|
+
const b = c / rows;
|
|
171
|
+
const w = 1 - a - b;
|
|
172
|
+
const point = [0, 1, 2].map((k) => tack[k] * w + head[k] * a + clew[k] * b);
|
|
173
|
+
// Fullest in the middle of the cloth, none on its edges.
|
|
174
|
+
point[0] +=
|
|
175
|
+
camber * Math.sin(Math.PI * Math.min(1, a * 1.6)) * Math.sin(Math.PI * (b * 0.8 + 0.1));
|
|
176
|
+
positions.push(...point);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const at = (r, c) => {
|
|
180
|
+
let offset = 0;
|
|
181
|
+
for (let i = 0; i < r; i += 1) {
|
|
182
|
+
offset += rows - i + 1;
|
|
183
|
+
}
|
|
184
|
+
return offset + c;
|
|
185
|
+
};
|
|
186
|
+
for (let r = 0; r < rows; r += 1) {
|
|
187
|
+
for (let c = 0; c < rows - r; c += 1) {
|
|
188
|
+
indices.push(at(r, c), at(r + 1, c), at(r, c + 1));
|
|
189
|
+
if (c < rows - r - 1) {
|
|
190
|
+
indices.push(at(r, c + 1), at(r + 1, c), at(r + 1, c + 1));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const geometry = new THREE.BufferGeometry();
|
|
195
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
196
|
+
geometry.setIndex(indices);
|
|
197
|
+
geometry.computeVertexNormals();
|
|
198
|
+
return geometry;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sailMaterial() {
|
|
202
|
+
return new THREE.MeshStandardMaterial({
|
|
203
|
+
color: COLOURS.sail,
|
|
204
|
+
roughness: 0.85,
|
|
205
|
+
side: THREE.DoubleSide
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function createProceduralBoat() {
|
|
210
|
+
const root = new THREE.Group();
|
|
211
|
+
|
|
212
|
+
const hull = mesh(buildHull(), 0xffffff, {
|
|
213
|
+
vertexColors: true,
|
|
214
|
+
roughness: 0.4,
|
|
215
|
+
side: THREE.DoubleSide
|
|
216
|
+
});
|
|
217
|
+
root.add(hull);
|
|
218
|
+
root.add(mesh(buildDeck(), COLOURS.deck, { roughness: 0.8, side: THREE.DoubleSide }));
|
|
219
|
+
|
|
220
|
+
const cabin = mesh(new THREE.BoxGeometry(0.15, 0.045, 0.26), COLOURS.cabin);
|
|
221
|
+
cabin.position.set(0, DECK_AT_MAST + 0.022, -0.06);
|
|
222
|
+
root.add(cabin);
|
|
223
|
+
for (const side of [-1, 1]) {
|
|
224
|
+
const glass = mesh(new THREE.BoxGeometry(0.004, 0.018, 0.16), COLOURS.glass, {
|
|
225
|
+
roughness: 0.2
|
|
226
|
+
});
|
|
227
|
+
glass.position.set(side * 0.0755, DECK_AT_MAST + 0.028, -0.06);
|
|
228
|
+
root.add(glass);
|
|
229
|
+
}
|
|
230
|
+
const hatch = mesh(new THREE.BoxGeometry(0.1, 0.008, 0.07), COLOURS.glass, { roughness: 0.3 });
|
|
231
|
+
hatch.position.set(0, DECK_AT_MAST + 0.048, 0.02);
|
|
232
|
+
root.add(hatch);
|
|
233
|
+
|
|
234
|
+
const mast = mesh(new THREE.CylinderGeometry(0.004, 0.005, MAST_HEIGHT, 8), COLOURS.metal, {
|
|
235
|
+
metalness: 0.6,
|
|
236
|
+
roughness: 0.4
|
|
237
|
+
});
|
|
238
|
+
mast.position.set(0, DECK_AT_MAST + MAST_HEIGHT / 2, MAST_Z);
|
|
239
|
+
root.add(mast);
|
|
240
|
+
|
|
241
|
+
// The mainsail turns about the mast; the boom goes with it.
|
|
242
|
+
const mainPivot = new THREE.Group();
|
|
243
|
+
mainPivot.position.set(0, DECK_AT_MAST, MAST_Z);
|
|
244
|
+
const boom = mesh(new THREE.CylinderGeometry(0.0032, 0.0032, BOOM_LENGTH, 6), COLOURS.metal, {
|
|
245
|
+
metalness: 0.6
|
|
246
|
+
});
|
|
247
|
+
boom.rotation.x = Math.PI / 2;
|
|
248
|
+
boom.position.set(0, 0.05, -BOOM_LENGTH / 2);
|
|
249
|
+
mainPivot.add(boom);
|
|
250
|
+
const mainCamber = 0.03;
|
|
251
|
+
const mainsail = new THREE.Mesh(
|
|
252
|
+
sailGeometry(
|
|
253
|
+
[
|
|
254
|
+
[0, 0.052, 0],
|
|
255
|
+
[0, MAST_HEIGHT - 0.01, 0],
|
|
256
|
+
[0, 0.056, -BOOM_LENGTH + 0.01]
|
|
257
|
+
],
|
|
258
|
+
mainCamber
|
|
259
|
+
),
|
|
260
|
+
sailMaterial()
|
|
261
|
+
);
|
|
262
|
+
mainPivot.add(mainsail);
|
|
263
|
+
root.add(mainPivot);
|
|
264
|
+
|
|
265
|
+
// The jib is fixed at the bow and the masthead; only its clew swings, so it
|
|
266
|
+
// is rebuilt when the angle changes, and the boat's own frame stays put.
|
|
267
|
+
const jibTack = [0, FORESTAY_DECK + 0.02, FORESTAY_Z - 0.02];
|
|
268
|
+
const jibHead = [0, DECK_AT_MAST + MAST_HEIGHT - 0.04, MAST_Z + 0.012];
|
|
269
|
+
const jibClewLength = 0.3;
|
|
270
|
+
const jib = new THREE.Mesh(new THREE.BufferGeometry(), sailMaterial());
|
|
271
|
+
root.add(jib);
|
|
272
|
+
const stay = new THREE.Mesh(
|
|
273
|
+
new THREE.CylinderGeometry(0.0012, 0.0012, 1, 4),
|
|
274
|
+
material(COLOURS.metal, { metalness: 0.6 })
|
|
275
|
+
);
|
|
276
|
+
const stayFrom = new THREE.Vector3(...jibTack);
|
|
277
|
+
const stayTo = new THREE.Vector3(...jibHead);
|
|
278
|
+
stay.position.copy(stayFrom).add(stayTo).multiplyScalar(0.5);
|
|
279
|
+
stay.scale.y = stayFrom.distanceTo(stayTo);
|
|
280
|
+
stay.quaternion.setFromUnitVectors(
|
|
281
|
+
new THREE.Vector3(0, 1, 0),
|
|
282
|
+
stayTo.clone().sub(stayFrom).normalize()
|
|
283
|
+
);
|
|
284
|
+
root.add(stay);
|
|
285
|
+
|
|
286
|
+
let lastAngle = null;
|
|
287
|
+
function trimJib(angle) {
|
|
288
|
+
if (lastAngle !== null && Math.abs(angle - lastAngle) < 1e-4) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
lastAngle = angle;
|
|
292
|
+
const clew = [
|
|
293
|
+
-Math.sin(angle) * jibClewLength,
|
|
294
|
+
FORESTAY_DECK + 0.03,
|
|
295
|
+
jibTack[2] - Math.cos(angle) * jibClewLength
|
|
296
|
+
];
|
|
297
|
+
jib.geometry.dispose();
|
|
298
|
+
// Bellying to leeward, the same side as the boom.
|
|
299
|
+
jib.geometry = sailGeometry([jibTack, jibHead, clew], -Math.sign(angle || 1) * 0.022);
|
|
300
|
+
}
|
|
301
|
+
trimJib(0);
|
|
302
|
+
|
|
303
|
+
// The pulpit and stanchions would be lost at this size; a rudder and a
|
|
304
|
+
// little cockpit coaming are not.
|
|
305
|
+
const coaming = mesh(new THREE.BoxGeometry(0.11, 0.014, 0.1), COLOURS.deck, { roughness: 0.8 });
|
|
306
|
+
coaming.position.set(0, DECK_AT_MAST + 0.006, -0.36);
|
|
307
|
+
root.add(coaming);
|
|
308
|
+
|
|
309
|
+
function update(pose) {
|
|
310
|
+
mainPivot.rotation.y = pose.sail;
|
|
311
|
+
// The cloth bellies to leeward: the side the boom is on.
|
|
312
|
+
mainsail.scale.x = pose.sail <= 0 ? 1 : -1;
|
|
313
|
+
trimJib(pose.sail * 0.8);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function dispose() {
|
|
317
|
+
root.traverse((child) => {
|
|
318
|
+
child.geometry?.dispose();
|
|
319
|
+
child.material?.dispose();
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { root, update, dispose };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The crew's model, sized to one unit long, its lowest point just under the
|
|
327
|
+
// waterline and its middle on the origin. Its bow must be towards +z with y up
|
|
328
|
+
// — the glTF convention — because nothing else says which end is the front.
|
|
329
|
+
export function normaliseCustomModel(scene) {
|
|
330
|
+
const holder = new THREE.Group();
|
|
331
|
+
holder.add(scene);
|
|
332
|
+
const box = new THREE.Box3().setFromObject(scene);
|
|
333
|
+
const size = box.getSize(new THREE.Vector3());
|
|
334
|
+
const centre = box.getCenter(new THREE.Vector3());
|
|
335
|
+
const length = Math.max(size.x, size.z, 1e-6);
|
|
336
|
+
const scale = HULL_LENGTH / length;
|
|
337
|
+
scene.position.set(-centre.x, -box.min.y, -centre.z);
|
|
338
|
+
holder.scale.setScalar(scale);
|
|
339
|
+
// Its keel below the waterline, its topsides above.
|
|
340
|
+
holder.position.y = -DRAUGHT;
|
|
341
|
+
const wrapper = new THREE.Group();
|
|
342
|
+
wrapper.add(holder);
|
|
343
|
+
return wrapper;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The sails a crew's model has, by the names of its nodes: what the boat will
|
|
347
|
+
// trim, as `[{ node, role }]`.
|
|
348
|
+
export function customSails(scene) {
|
|
349
|
+
return findSails(scene);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function createCustomBoat(template) {
|
|
353
|
+
const root = template.clone(true);
|
|
354
|
+
// Each sail turns about the vertical axis through its own origin, from where the
|
|
355
|
+
// model has it at rest: the crew put the origin on the mast (or the forestay).
|
|
356
|
+
const sails = customSails(root).map(({ node, role }) => ({
|
|
357
|
+
node,
|
|
358
|
+
factor: SAIL_ROLES[role].factor,
|
|
359
|
+
rest: node.rotation.y
|
|
360
|
+
}));
|
|
361
|
+
return {
|
|
362
|
+
root,
|
|
363
|
+
update(pose) {
|
|
364
|
+
for (const { node, factor, rest } of sails) {
|
|
365
|
+
node.rotation.y = rest + pose.sail * factor;
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
dispose() {}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Which parts of a crew-supplied boat model are its sails.
|
|
2
|
+
//
|
|
3
|
+
// The default boat sets its own sails from the wind; a model loaded from a .glb
|
|
4
|
+
// can have the same done to it, provided its sails are named so they can be found
|
|
5
|
+
// (README, "Your own boat"). Nothing here touches the 3D library, so plain Node
|
|
6
|
+
// can test it; the boat model does the rotating.
|
|
7
|
+
|
|
8
|
+
// What each kind of sail does, relative to the wind-driven angle the boat is given
|
|
9
|
+
// (see boat-pose.mjs `sailAngle`): the mainsail follows it, the headsail a little
|
|
10
|
+
// less, as it is sheeted in closer.
|
|
11
|
+
export const SAIL_ROLES = {
|
|
12
|
+
main: { factor: 1 },
|
|
13
|
+
jib: { factor: 0.8 }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Names are compared without case, punctuation or the number a modelling tool adds
|
|
17
|
+
// to a duplicate (Mainsail.001 arrives as Mainsail001), and in English or French.
|
|
18
|
+
const NAMES = {
|
|
19
|
+
main: ['main', 'mainsail', 'sailmain', 'grandvoile', 'gv'],
|
|
20
|
+
jib: ['jib', 'genoa', 'headsail', 'foresail', 'sailjib', 'foc', 'genois']
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function normalise(name) {
|
|
24
|
+
return String(name ?? '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.normalize('NFD')
|
|
27
|
+
.replace(/[̀-ͯ]/g, '')
|
|
28
|
+
.replace(/[^a-z0-9]/g, '')
|
|
29
|
+
.replace(/\d+$/, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 'main', 'jib' or null.
|
|
33
|
+
export function sailRole(name) {
|
|
34
|
+
const key = normalise(name);
|
|
35
|
+
if (!key) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return Object.keys(NAMES).find((role) => NAMES[role].includes(key)) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The sails of a model, given its nodes as { name, children }: each sail found,
|
|
42
|
+
// leaving out any node inside another sail (the outermost one is what turns).
|
|
43
|
+
export function findSails(root) {
|
|
44
|
+
const found = [];
|
|
45
|
+
const visit = (node) => {
|
|
46
|
+
const role = sailRole(node.name);
|
|
47
|
+
if (role) {
|
|
48
|
+
found.push({ node, role });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const child of node.children ?? []) {
|
|
52
|
+
visit(child);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
visit(root);
|
|
56
|
+
return found;
|
|
57
|
+
}
|