Skip to content

About

SVG renderer for vessels, navigation lights and rules-of-the-road scenes. Sibling of coast-wright and wire-wright.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

nav-wright

Renderer for vessels, navigation lights, and rules-of-the-road scenes: profile, bearing (lights-only, seen from a relative bearing θ) and plan (arc sector) views in SVG, plus an optional 3D view of the vessel as an orbitable model.

Try the playground! https://mark-brannan.github.io/nav-wright/.

Consumed by searoom:

Sibling of:

Install

npm install nav-wright

react is a peer dependency; colregs-engine comes along as a dependency, for the FactRecord / LightsData / Arc types the renderer is written against.

three, @react-three/fiber and @react-three/drei are optional peer dependencies, needed only by nav-wright/benchy (below). Nothing in the main entry point imports them, so a consumer who only renders the SVG views installs none of them.

What it exports

Export What it is
selectHull(facts), allHulls, Hull, HullSpec, PX, PZ the curated base-drawing set, and the profile-view coordinate mapping
placeLights(displayLights, hull, facts, lightsData, geometry?) → PlacedLight[] a lawful display turned into lights positioned on a hull
bearingInArc(bearing, arc) is a light visible from this relative bearing — the exam-faithful part; arcs come verbatim from the data, not from geometry invented here
ProfileView, PlanView, BearingView, bearingLabel the three scene components
TowPlanView, TowProfileView, towGroupFromRecords, TowGroup, towThreshold, layoutTow a tow or pushed group (Rule 24) as one composition: every vessel with her own display (see Tows and pushed groups)
Glow, lightFill, LIGHT_COLORS, polar, sectorPath the SVG primitives the views are built from
SceneLabels, defaultSceneLabels, Aspect the label interface
ThetaControl the relative-bearing slider, shared with the 3D view
hullStyles, hullStyleIds, HullStyle, HullStyleId, Fill, Hatch, Paint the hull styles: ways to paint every hull (see Hull styles)

placeLights takes its light data as an argument rather than importing it, so nothing here depends on a particular app's copy of the COLREGS data. Give it colregs' geometry.json as the fifth argument and, for a vessel with a fact:length_m, light heights and vertical separations follow Annex I (masthead height by length band, the after masthead 4.5 m higher, sidelights under three quarters of the masthead, vertical lights 2 m or 1 m apart). Each light it places that way carries heightM and basis (the Annex I paragraphs, e.g. AnxI.2(a)(i)) so a consumer can label it; anywhere the geometry is silent the hull's hand anchors stand.

The 3D view

nav-wright/benchy is a separate entry point, so that importing nav-wright never pulls three.js into a bundle that has no use for it. test/entrypoints.test.ts walks the import graph and fails if it ever does.

import { BenchyView } from 'nav-wright/benchy';
import modelUrl from 'nav-wright/models/3dbenchy-lowpoly.glb?url'; // Vite

<BenchyView
  modelUrl={modelUrl}
  hull={hull}
  placed={placed}
  facts={facts}
  theta={theta}
  onTheta={setTheta}
  tilt={tilt}
  onTilt={setTilt}
/>;
Export What it is
BenchyView the vessel as an orbitable 3D model, with her lights seated on the mesh
DEFAULT_TILT, MAX_TILT the camera-elevation range, for clamping app state against
TiltControl the camera-elevation slider
ModelErrorBoundary degrades a failed model load to a fallback rather than a blank pane
preloadBenchyModel(url) warms the model cache; loads three.js on call, not on import
BenchyLabels, defaultBenchyLabels SceneLabels plus the 3D view's own labels

The model URL is a prop, not a built-in. The package ships the mesh at nav-wright/models/3dbenchy-lowpoly.glb (CC0 — see MODELS.md for provenance and the bow-orientation check the renderer depends on), but a library cannot know how a consumer's bundler turns an asset into a URL, so resolving it is the consumer's job. Copy it into your own static assets and pass that path if you would rather not resolve it through a bundler.

The mesh is fitted to the hull's own span. hull carries the spec's sternX and bowX; the mesh is scaled so its length runs from one to the other, not across the full length, so its stern, bow and heights share the frame the spec's light stations are drawn in. Without a hull it is fitted to the full length and centred.

A failed model load — offline, a blocked CDN, no WebGL context — falls back to ProfileView, on the reasoning that the lights are the subject and losing them is worse than losing the hull rendering.

The station profile

nav-wright/profile is a separate entry point for the maths that seats the lights on the 3D mesh. Nothing in it touches React or @react-three/fiber; the only import that takes a three.js value is profileHullModel, so a consumer can run it under a plain Node test environment. Build a StationProfile from a loaded scene the way BenchyView does, then compare anchorLights()'s seats against a HullSpec without copying the pipeline.

import { anchorLights, profileHullModel } from 'nav-wright/profile';

const profile = profileHullModel(scene, lengthMeters, span); // StationProfile | null
Export What it is
profileHullModel(scene, lengthMeters, span?) places a glTF scene as a vessel of that length and reads its shape back as a StationProfile
placeHullModel, sampleEdges, stationProfile, stationAt the steps behind it: placement, edge sampling, the per-station profile, a lookup at one x
anchorLights(lights, lengthMeters, hull, profile, clearance?), lightPosition seats the spec's lights on the profile
PROFILE_STATIONS, DECK_EDGE_BAND, MAST_BAND, SIDE_LIGHT_BAND, BOW_SHOULDER, bowShoulder the sampling and seating constants
MODEL_ROTATION_X, LIGHT_Z_TO_X the model's rotation and the light-height scale
specBlock, BLOCK_MAST, growPrism, insidePrism, blockTriangles a blocky hull built from the spec, with every light inside it
types HullSpan, HullStations, LightSeat, ModelBox, ModelPlacement, Station, StationProfile, Vec3, BlockLight, BlockPrism, BlockSpec the shapes the functions take and return

profileHullModel returns null when the mesh yields no profile.

Tows and pushed groups

A TowGroup is a lead vessel and the hulls she tows or pushes, each a SceneVessel with her own placed lights, so every vessel of the group keeps her own lawful display. towGroupFromRecords(lead, members, data) builds one from each vessel's fact record and the display the engine evaluated for it, running selectHull and placeLights (and placeShapes, given shapes and geometry data) per record. A member's tie comes from her fact:tow_rigidity (24(f)), else from the lead: astern on a line, alongside, or pushed ahead. With the lead's fact:composite_unit, the group is 24(b)'s one power-driven vessel and only the lead's display is drawn. Her record then describes the unit: fact:length_m is the whole unit's, so 23(a) is evaluated for that length, and her own drawn length goes in the record's lengthM.

TowPlanView draws the group from above with each hull's arc rings, the tow line or push contact, and the tow length against Rule 24(a)(i)'s threshold; TowProfileView stands the hulls end to end on one waterline, lights by night and day shapes by day. Neither hardcodes the threshold: pass towThreshold(applicabilityJson), which reads it, and the masthead counts on either side of it, from colregs' towing entries.

colregs-engine 0.1.9 does not yet accept fact:tow_rigidity or fact:partly_submerged, which colregs' 24(f) and 24(g) entries read, so those vessels' displays cannot come from the engine until it does.

Labels

The scene components carry no i18n framework. Each takes an optional labels: SceneLabels prop and falls back to defaultSceneLabels, which is English. A consumer with a real catalog builds a SceneLabels from it and passes it down — that is how searoom wires its react-intl messages in. test/labels.test.tsx renders all three components with no provider around them, which is what keeps this seam honest.

Styling

The components emit their own class names and read colors from CSS custom properties; they ship no stylesheet. A consumer must define:

  • properties: --hull-fill, --hull-stroke, --rig-stroke, --sea-night, --sea-below, --waterline, --grid-line, --accent-soft
  • classes: scene-svg, scene-svg-black, plan-svg, bearing-view, bearing-empty, theta-control, theta-readout, and light-flash for the flashing-light animation; nav-wright/benchy additionally uses model-view, scene-3d and scene-3d-stack

BearingView's opt-in seascape presentation also reads --bearing-sea-near, --bearing-sea-far and --bearing-horizon; each has a built-in fallback, so defining them is optional.

The lights themselves are literal colors, not properties, so they are correct whatever the consumer does. Everything else — hull, sea, rigging, grid — is undefined until these are set. Whether nav-wright should ship a default stylesheet is an open question, not a decision.

Hull styles

A style is a set of variants for the whole roster, not a candidate in a contest: every style ships, and each one paints all of allHulls. Pass one to any view, and the same class can be drawn differently from one screen or setting to the next:

import { ProfileView, hullStyles } from 'nav-wright';

<ProfileView hull={hull} placed={placed} facts={facts} style={hullStyles.moonlit} />;
Style Look
painted soft gradient fills on hull and sails, a dusk sky, the hull mirrored in sea
plate clean flat fills, engraved hatching, crisp ink lines: the pilot-book look
moonlit near-silhouette: dark forms, a cold rim of moonlight, the lights dominate

ProfileView, PlanView and BearingView take the optional style; given none, a view draws the base drawing exactly as before. A style is a paint layer over the base drawing, not a redrawing: it scopes --hull-fill, --hull-stroke, --rig-stroke and the sea properties to one <svg>, points them at its own gradients and patterns, and adds what the line drawings leave out (sails, set only while the vessel is underway; a reflection) in the drawing's own coordinates. No HullSpec changes, so the lights land in the same place in every style, and the light colours stay literal. In the bearing view a style paints only the hull hint and the seascape; the sky and the lights are the exam view's.

The playground's gallery page (npm run dev, then /gallery.html; also on the published playground) shows every hull in every style in all three views. Its vessels page (/vessels.html) shows real sailing vessels traced from published drawings, each lofted into 3D (see MODELS.md).

Development

npm ci
npm run typecheck
npm test
npm run build

npm run build is what emits dist/ with types; it also runs on install from a git ref via prepare, which is how the github: pin above works.

npm run dev opens the playground: every hull in every view, with each prop as a control. The latest main is hosted at https://mark-brannan.github.io/nav-wright/.

npm run render builds the playground and photographs every scene in playground/namedScenes.json in headless Chromium, WebGL included: one PNG per scene in renders/ (gitignored) plus renders/contact-sheet.png. The first run needs a browser: npx playwright install chromium. CI runs it on every pull request and uploads renders/ as an artifact.

The scene PNGs are committed in goldens/ as well (not the contact sheet). A pull request that changes any of them gets a score per image (changed pixels, SSIM, changed regions) from the shared golden-diff workflow, as a comment and in its checks. To move a golden on purpose, run npm run render and copy renders/<name>.png over goldens/<name>.png.

About

SVG renderer for vessels, navigation lights and rules-of-the-road scenes. Sibling of coast-wright and wire-wright.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages