Skip to content

Repository files navigation

Dronea — Jet-Powered (ISR) Drone Concept

A 3D product presentation for two stealth airframes, built from the two GLB scans in this repository. React + Vite + React Three Fiber + GSAP.

npm install
npm run dev        # http://localhost:5173
npm run build

The models

Two scans ship with the repo. They share a convention, which is why no per-model axis correction is needed: nose toward +Y, dorsal surface toward +Z, wingspan normalised to 1.0. Viewed down +Z you get the planform the design calls for, with no rotation applied.

source optimised triangles
aircraft (ISR recon) 13.6 MB 937 KB 45,276
jet (UCAV strike) 16.4 MB 947 KB 44,484

The raw exports pair a 45k-triangle mesh with an 8192×8192 baked JPEG — far too heavy to ship. npm run optimize:models resizes the textures to 2048 WebP and compresses geometry with meshopt (its decoder ships inside three-stdlib, so there is no CDN round-trip at runtime). Simplification is deliberately off: it faceted the wing leading edges at hero scale.

Selector thumbnails are renders of those same GLBs, not stand-in artwork — npm run thumbnails, which drives scripts/thumb.html with the same material, lights and tone mapping as the hero. They double as the no-WebGL fallback image.


Architecture

src/
  data/drones.ts        one entry per variant: model, copy, annotations, framing
  state/scene.tsx       activeIndex / previousIndex / isTransitioning / ready
  three/
    framing.ts          camera + annotation projection maths (shared)
    choreography.ts     every drone movement, as GSAP timelines
    DroneModel.tsx      loads, centres and re-materials one airframe
    DroneStage.tsx      owns all instances and runs the transitions
    Lighting.tsx        procedural studio environment (no HDRI fetch)
  components/           the DOM composition
  lib/                  measurement, media queries, intro + copy-swap timing

React state is coarse on purpose

The store holds application state only. Position, rotation and idle phase live on Three.js objects and are driven by GSAP and useFrame, so a full drone switch costs exactly two React renders. Nothing re-renders per frame.

Four nested groups per airframe

outer   GSAP only      — the flight path: rise, spin, scale
drag    controller only — viewer rotation
idle    useFrame only  — the breathing float, additive on top
inner   static         — bounding-box centring + axis normalisation

Each animator owns a different node, so none of them can write the same property as another, fight over it, or leave residue. Visibility is owned by the choreography and never by a React prop — if React could flip visible, a re-render mid-transition would cut the departing airframe out of frame while it was still flying.

Drag to inspect

Dragging rotates the model, never the camera. An orbiting camera would break the annotation projection below — which is only linear because the camera sits square on +Z — and would mean the composition reframed itself every time someone nudged the drone.

Release to coast, and the airframe stays exactly where you left it — it does not drift home. Vertical travel is clamped to ~35°; a viewer that lets you flip the aircraft onto its back is a toy.

The callouts hide for the duration of the motion — swinging leader lines are noise — and come back the moment it stops, re-projected against whatever pose it was left in. The controller reports only the start and end of movement, so a whole inspection costs two React renders rather than one per frame.

On touch, touch-action: pan-y keeps the page scrolling while horizontal drags still reach the model. Starting a switch hands any leftover rotation to the departing airframe, which unwinds it during its spin, so the arriving model is always square.

One projection, two consumers

framing.ts computes the camera distance and projects annotation anchors. Anchors live in model space (wingspan units from the airframe centre), so a line pointing at a wingtip keeps pointing at that wingtip at every viewport and in every pose — there are no per-breakpoint or per-angle coordinate tables anywhere.

poseAnchor turns the anchor through the same group stack the renderer uses (hero tilt, then yaw, then the viewer's rake — Three's XYZ Euler order makes the drag matrix Rx·Ry), and projectPoint finishes with a real perspective divide. At z = 0 that reduces exactly to the linear map the framing is derived from; off that plane — which is where an anchor lands once the model is turned — the divide by depth is what keeps the leader welded to the point it names.

Which side a label sits on is derived from where its anchor actually lands, not configured. At rest that reproduces the intended left/right split exactly; turn the model far enough to carry a point across the centreline and the leader follows it out to the near edge instead of dragging a line back across the airframe.

The switch

The revolution is about world Y — the screen-vertical axis — so the airframe turns like a model on a turntable and shows its underside halfway through, which on these scans means the landing gear and the external stores. Because the planform is thin, a pure Y turn passes through a frame where the silhouette all but vanishes; a 0.34rad rake is eased in for the duration of each turn to keep a readable surface at every angle. It is applied to the parent of the spin (Euler XYZ puts X outermost), so it holds steady in world space while the model rotates beneath it, and it unwinds before the airframe moves on.

The beats are sequential rather than blended, so each reads as one clear idea before the next begins:

1  spinOut 0.95s  sine.inOut  outgoing turns a full revolution on the spot
2  exit    0.78s  power1.in   climbs straight up and out, no rotation
3  gap     0.10s              a beat of empty stage
4  enter   0.92s  power1.out  incoming rises straight up from below, no rotation
5  spinIn  1.05s  sine.inOut  having arrived, turns a full revolution
   settle  0.42s  sine.inOut  damps back off a deliberate overshoot

~3.5s end to end, and deliberately unhurried — a switch is the one moment the viewer gets to watch the product move. Every curve is a sine or a single-power ease rather than a power2+: the higher powers have a sharp knee that reads as a snap over durations this long, which is exactly what a slow revolution must not do.

Travel distance is part of the feel, not just the timing. Both legs are flown square, so half an airframe length past the frame edge already hides the model — and the shorter the travel, the gentler the motion needed to cover it in the same time. The entry clearance is the tighter of the two on purpose: every unit of it is time the arriving airframe spends below the frame, which the viewer reads as an empty stage rather than as an arrival.

The seams are overlapped — 80ms on the exit, 180ms on the arrival. An eased-in turn covers only a few degrees in its first 100ms, so a strict hand-off reads as the airframe stopping dead before it moves again. The overlaps are small enough that the beats still read as sequential — nothing appears to climb and rotate at once — but large enough to remove the hitch.

Both models stay mounted for the life of the page (parked invisible), so a switch never waits on the network and never pops a half-decoded mesh into frame. Selection is locked for the duration and guarded in the reducer, so double-clicks, key repeats and stray programmatic calls all funnel through one check and can never start overlapping timelines.

The copy is deliberately not swapped on click — useCopySwap delays it until STAGE_EMPTY_AT, the window after the old airframe has gone and before the new one appears. That hides the change, and it also puts the cost of re-rendering the display type where a lost frame cannot show.


Responsive

The layout switches to a single column below 760px or in portrait (STACKED_QUERY), because a tall narrow board strands the copy at the bottom of a dead field. The same query drives the CSS and the 3D tier, and the camera is fitted to the measured stage box rather than the board — on the stacked layout those differ a lot.

Callouts adapt rather than overflow: four connector lines on desktop, the two primaries on tablet, and on the stacked layout the geometry is dropped for a compact spec strip under the model, since a connector on a 340px-tall stage either points at nothing or crosses the airframe it describes.

Accessibility & resilience

  • The selector is a radiogroup with a roving tabindex and arrow-key traversal. It uses aria-disabled while locked, not disabled — the real attribute blurs the element and would drop focus mid-animation.
  • prefers-reduced-motion skips the intro and swaps airframes instantly; switching still works.
  • No WebGL: the canvas is replaced by a render of the same model, and the rest of the page is untouched — nav, copy, CTA and switching all still work.
  • Measured at 60.0 fps / 17.5 ms p95, zero dropped frames through a transition at 1440×900, over five consecutive switches. The session's first switch drops one 33 ms frame at t+68 ms — first-run JIT of the timeline code, before anything has visibly moved. Chased it with an explicit gl.compile() warm-up first; that changed nothing (drei's <Preload all /> already covers it) so the warm-up was removed rather than kept as speculative code.
  • Drag inertia and the drift back to rest are disabled under reduced motion; rotation itself still works, since it is the viewer's own input.

Attribution

The trust strip uses monochrome typographic treatments of company names (nominative use) with neutral geometric glyphs — no brand logo artwork ships here. Drop licensed assets into that grid unchanged when they are available.

About

A 3D jet drones exploration website developed in Three.js using Claude Opus 5

Resources

Stars

15 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages