BlackBeltTechnology

deck3d

'Build a self-contained 3D presentation from a Markdown outline — parse, tune via `overrides`, render to one offline deck.html. Use on "make a 3D deck from this outline", "build a presentation from these notes", "deck3d this markdown", "turn this talk into a 3D deck".'

BlackBeltTechnology 319 49 Updated 3d ago

Resources

3
GitHub

Install

npx skillscat add blackbelttechnology/pi-agent-dashboard/deck3d

Install via the SkillsCat registry.

SKILL.md

deck3d

Turn a Markdown outline into one self-contained, offline deck.html: extruded 3D
titles, harvested mermaid diagrams built as 3D objects, animated backgrounds, and
an optional glTF prop. The converter code is deterministic; you tune the result
by writing overrides in deck.json
, never by editing the HTML.

When to use

  • The user has an existing .pptx: follow `deck3d-from-pptx`
    (import, reference renders, then a variant-and-ask step per styling decision).
  • The user has a Markdown outline (headings + bullets + ```mermaid blocks) and
    wants a presentation they can open offline in a browser.
  • You need to tune placement, legibility, mode, effects or props of an existing
    deck.json.

Markdown grammar

  • --- front-matter: deck defaults (mode, palette, material, quality, transition, …).
  • # Title starts a slide. Zero headings ⇒ one slide id slide.
  • The first paragraph after the heading (before bullets) is the subtitle.
  • - bullet lines are body bullets.
  • A fenced ```mermaid flowchart / sequenceDiagram block becomes the slide's
    diagram (harvested in headless chromium). Any other diagram type ⇒ warn + no diagram.
  • <!-- deck3d: {...} --> inline overrides win over deck.json and warn on clobber.
    The comment may span several lines or be written as a bullet (- <!-- deck3d: {...} -->);
    in every form it is read as an override and is never the subtitle or a bullet.
  • Pin a slide id with # Final {#outro} so renames never orphan tuning.

The tune loop

Interactive authoring path: deck3d serve talk.md [--check]. Watches sources,
rebuilds on change, reloads in place, re-pins local fx hashes, and lets the
configurator save or apply overrides directly.

Manual headless / agent / CI path:

  1. deck3d parse talk.md → talk.json (derived IR + empty overrides).
  2. deck3d validate talk.json → schema + derived-edit + orphan/prop warnings.
  3. deck3d build talk.md -o talk.html → talk.json + talk.html; runs check
    and prints style: <n>/<N> slides styled.
  4. Style — mandatory, and the step that decides whether the deck looks
    designed. deck3d check talk.html --style warns style-defaults for every
    slide still running only what parse chose. For each one:
    • pick a corpus effect (deck3d fx list --topic geo, --kind background;
      the stylistic set — clipped-solids, extruded-shapes, volume-cloud,
      curve-flow, … — is never auto-picked, so it is always an explicit choice)
      or write a per-deck one with deck3d fx scaffold <name> --for <slideId>;
    • give a content slide without a mermaid block a built topology
      (overrides.slides["<id>"].diagram.kind + data);
    • illustrate the section: deck3d props search <kw> --role hero,
      --role illustration, and props search --role ambient for
      low-triangle background instances;
    • if extruded titles read as a solid block, set titleEdge: "contrast"
      (deck default, or per slide) — it outlines every character in the
      palette text colour;
    • for a deck that wants a horizon, set overrides.deck.floor: "water"
      (one global surface; per-slide is not a thing);
    • the floor reflects the backgrounds too; overrides.deck.reflectBackdrop: false limits the reflection to slide content (one less backdrop pass per
      frame), and overrides.deck.floorMatte (0-1) roughens it — 0 is a perfect
      mirror, ~0.35 reads as polished concrete, ~0.85 as scattered stone (the
      ⚙ panel drags it on a slider), while overrides.deck.floorReflectivity
      (0-1, default 1) sets how STRONG the reflection is; all deck-scope only;
    • grade the frame with a post card (deck3d fx list --kind post): add
      { "id": "outline", "params": { "parts": "diagram" } } (or
      selective-bloom, god-rays, depth-of-field, pixelate, ascii, …)
      to the slide's effects[]. One or two per slide; they stack in a fixed
      order, and check flags declared conflicts.
  5. Read the check findings. Fix only the suggested key (e.g.
    overrides.slides["arch"].diagram.scale), then re-run step 3.
  6. deck3d snapshot talk.html --slide 5 -o s5.png to eyeball one slide.
  7. Repeat until check is clean and the style line accounts for every slide
    you meant to design.

Tune by writing deck.json's overrides only:

overrides: {
  deck:    { ...defaults knobs... },
  effects: [{ id, params? }],
  props:   [{ source, id, licence, author, sha256, slide, role, size?, count?, restyle?, anim? }],
  slides:  { "<slideId>": { mode?, palette?, material?, scene?, quality?,
             diagram: { scale?, offset? }, camera: { distance? }, labels: { size? },
             check: { ignore? }, effects? } },
  nodes:   { "<slideId>/<nodeId>": { shape?, label?, position?, size?, material? } },
  edges:   { "<slideId>/<edgeId>": { kind?, material? } }
}

Objects deep-merge; arrays replace (an effects/props list is the whole list);
diagram.data replaces as a whole object so labels and values never mix provenance.
Full field list: `reference/ir-fields.md`.

The deck's ⚙ configurator (C key, or the gear) tries knobs live. Its Effects
block ticks composed effects off and adds any corpus or local: card from the
add effect… picker; both rebuild the slide immediately. Under
deck3d serve, Apply to deck.json merges straight into talk.json and rebuilds;
Save overrides.json writes overrides.json beside the deck. In unserved decks
or headless workflows, Export writes an overrides.json in exactly this grammar.
Merge it manually with:

deck3d overrides apply talk.json overrides.json

It deep-merges, re-validates, and refuses to write on a validation error. Note
that markdown inline overrides win over anything in that file, and an
exported effects list pins that scope's whole list.

Styling with effects

deck3d fx list [--kind k] [--tag t] [--json] prints the catalogue;
`reference/effects.md` has params and licences.

  • parse assigns deterministic defaults (title→swarm, flowchart→tokens,
    sequence→rings, security→glyph-rain, data→data-columns), then routes the
    rest by topic (deck3d fx list --topic <t>; ai agents geo trust security compute data money work timeline sales process). autoStyle: false restores
    the plain v1 fallback.
  • Replace them with overrides.slides["<id>"].effects = [{ id, params? }].

Video on a slide

video-screen puts a looping clip on a framed screen standing in the scene:

{ "id": "video-screen", "params": { "src": "video/demo.mp4", "width": 7.5, "x": 5, "y": 1, "tilt": -0.25 } }

The clip MUST be same-origin, or the browser taints the canvas and the frame
never renders. Two ways to satisfy that:

  • deck3d serve serves media sitting beside the deck (video/demo.mp4), which
    is the authoring path.
  • deck3d render deck.json -o deck.html --embed-video inlines clips as data:
    URLs for ONE portable file. Opt-in, because base64 costs ~33% on top of the
    clip's own size.

A bare file://deck.html next to a video/ folder does NOT work — that clip is
cross-origin. Playback freezes during transitions and runs only while the slide
is settled, so check and snapshot stay deterministic.

Per-deck (local) effects

When no corpus effect fits the topic, write one beside deck.md:

deck3d fx scaffold neural-mesh --for ai   # writes fx/neural-mesh.{js,meta.json}
deck3d fx preview local:neural-mesh --palette ember -o p.png
deck3d fx hash neural-mesh                # re-pin after every edit

Paste the printed { id: "local:<name>", sha256 } entry into the slide's
effects. render embeds the source and refuses a stale hash, so the deck
stays byte-deterministic and offline.

Inside a local module the deck shadows the non-deterministic and I/O globals:
Math.random is the deck's seeded per-slide generator, and window,
document, fetch, setTimeout, Date, Promise, eval and friends are all
undefined. No import, one default export, 64 KiB cap. A throw disables that
effect only and shows up as local-fx-error in check; any network the deck
attempts shows up as local-fx-network. Promote a good one with
deck3d fx promote <name> --source <url> --licence <spdx>.

  • Conflicts fail render; mode-incompatible effects are skipped with a warning;
    the summed cost warns over the quality budget (low 6 / medium 12 / high 20).

Deck assets, layer effects and the pptx pipeline

  • overrides.assets[] — { id, path, sha256, mime } pins files (PNG/JPEG/GIF/WebP/SVG,
    JSON, text, font/ttf|otf) that render embeds once as data: URIs, so the HTML stays
    one offline file. path must stay inside the deck directory; a stale sha256 fails
    validate/render. Effects read them with ctx.assets.get(id) (decoded bitmap, parsed
    JSON, text, or a font family name) — also local effects, whose 64 KiB code cap is
    unchanged. Font assets use ids font-<name>. Budget: warn above 25 MB, fail above 200 MB.
  • Card kind layer draws slide content above the backdrop pass. replacesContent
    cards (slide-layer) drop the slide's native title/card/bullets/diagram; persistent
    cards (frame-chrome) are deck-scope only (overrides.effects), created once outside
    every slide group, driven by transition({from,to,p}).
  • Built-ins: slide-layer, frame-chrome, art3d, icon3d, haze (see
    `reference/effects.md`); deck3d fx preview <id> renders each
    against a packaged stub bundle.
  • Commands: deck3d pptx import|art trace|art icon|art cutout|variants render|pick|note|replay|compare
    — deck3d --help lists them; the end-to-end procedure is deck3d-from-pptx.
  • style.json beside deck.json records the picks (steps[].variants[], picked,
    note, round); validate checks it. serve previews a candidate at
    ?variant=<step>:<name> (V cycles) without writing anything.

Props (content illustrations)

deck3d props search <keywords> → candidate table (vendored first, then Poly Pizza).
You pick by relevance then style — code never picks. Then:

  1. Prefer one pack per deck and restyle: "palette".
  2. deck3d props fetch <source> <id> → prints the overrides.props[] entry
    (with the sha256) and caches it in .deck3d/props/.
  3. Add the entry to overrides.props (roles: hero, illustration,
    ambient, node:<id>), then build/snapshot.
  4. deck3d props search <kw> --role ambient filters to models cheap enough to
    instance in the background and prints a ready ambient entry.
  5. No model fits? deck3d props generate --prompt "<text>" --name <n> (or
    --from-image). Optional python path; network at authoring time only.

Licences: CC0 needs no credits; CC-BY (and any non-CC0, non-generated) produces a
last credits slide at render. Never fetch a model whose licence you cannot name.

Forbidden edits

  • Never edit deck.html — it is regenerated; tune deck.json overrides.
  • Never edit derived fields (slides[] and below) — validate warns
    edited outside overrides; the next parse loses the change.
  • If you need a knob that does not exist, propose a schema field — do not hack the HTML.

Pitfalls

  • parse of a deck with mermaid needs chromium; missing ⇒
    npx playwright install chromium. render/validate never need a browser.
  • Every three.js typeface.json has corrupt ő ű Ő Ű glyphs; deck3d uses a real
    Poppins TTF through opentype.js — do not swap in a typeface.json.
  • Extruded small text blooms and is unreadable; diagram labels are flat canvas
    planes with a background-colour outline. Tune labels.size, not the material.
  • Placement: defaults.rail (line orbit tunnel helix grid) strings the
    slides in space, defaults.spacing sets the gap, layout (split
    split-reverse) composes one slide, cardOffset nudges the card. Every rail
    frames a slide identically, so a rail switch never needs a per-slide re-tune.
  • Transitions = how the camera ENTERS a slide. Set defaults.transition, per
    slide overrides.slides["<key>"].transition, or deck-level
    defaults.transitionCycle: ["dolly","crane",...] (slides without their own
    transition take cycle[index % length]). durationSec and
    transitionAmount (0–2, scales offsets/roll/lens; 0 ≈ dolly) per deck/slide.
    Motions live in runtime/transition.ts (makeMotion), validated as an enum:
    id move
    dolly (default) straight eased move to the anchor
    swing dolly + sideways arc away from the mid-point
    revolve swings wide to travel-right, locks on early, arcs in around the slide
    crane rises over the gap (~0.3 × travel length), descends onto the slide
    bank dolly with roll into the move and back out (≤ 8°)
    flythrough low pass through the gap, slight bank, wider lens
    fade screen covers with the palette bg, camera switches, uncovers
    iris circular mask closes to centre, camera switches, reopens
    cut instant snap; old slide's local fx disposed at once
    path author keyframes from the slide's cameraPath
    cameraPath (needs transition: "path", else validate fails):
    "ertek": { "transition": "path", "durationSec": 2.2, "cameraPath": { "ease": "inOut", "keys": [
      { "t": 0.3,  "offset": [0.18, 0.22, 0], "look": 0.25, "roll": 6 },
      { "t": 0.68, "offset": [-0.08, 0.1, 0.04], "look": 0.9, "fov": 48 } ] } }
    Keys (1–8, t strictly increasing in (0,1)) sit in the TRAVEL FRAME:
    offset = [right, up, forward] as fractions of the from→to camera distance,
    so a path reads the same on every rail/spacing; start and end are implied and
    pinned (always lands exactly). look 0 = leaving slide, 1 = arriving; roll
    ±45°; fov 20–90°. Back navigation plays the LEAVING slide's move reversed
    (mirror image). Both slides stay un-culled for the whole move. Camera-pinned
    local fx (overlays parented via onBeforeRender) tilt with roll — keep
    roll small on decks with HUD-like overlays. rail stays deck-wide.
    Every flying move cross-fades: the leaving slide's backdrop (local fx +
    background) fades out over 5–60 % of the move, the arriving one fades in over
    10–70 % (whole group when it was culled). Local fx that set their own
    opacity still compose (fader re-bases); custom shaders without an
    opacity uniform only pop at the ends — expose uniforms.opacity.
  • During a fly both slides tick, the arriving slide's local fx are re-created
    at the START of the fly, and cullNeighbours() hides whole slide groups by
    distance — a camera-pinned overlay that must survive the fly has to reparent
    itself into the scene (keep shared state in THREE.Cache.files) — or, for deck-wide
    camera-pinned art, use a persistent card (frame-chrome), which the runtime creates
    once outside every slide group and drives with transition({from,to,p}).
  • The mermaid engine is pinned exactly; a bump is a deliberate change and the
    harvest fixtures will fail if the rendered id scheme moves.
  • Headless/background tabs stall requestAnimationFrame; the loop falls back to
    setTimeout so snapshots capture a finished frame.
  • A check: clean deck can still be bare. check measures fit and
    legibility, not ambition — read the style: <n>/<N> slides styled line and
    run check --style before calling a deck done.

Contributing an effect

Development-time procedure on the package (distinct from the deck tune loop):

  1. Search a source; check its licence against the allow-list: MIT, Zlib,
    BSD-2/3-Clause, CC0-1.0, Apache-2.0, OFL-1.1
    . LYGIA (Prosperity) and
    Shadertoy content (default CC BY-NC-SA) are inspiration only — never port
    them verbatim.
  2. Port to the module interface: create(ctx, params) → { object?, pass?, material?, tick?, dispose() }.
  3. Write the card src/fx/<id>.meta.json (kind, tags, cost, modes, params schema,
    conflicts, source URL, licence).
  4. deck3d fx preview <id> → check the PNG is not black.
  5. npm run gen:effects → regenerate reference/effects.md.
  6. npm test → the corpus test validates every card and the catalogue hash.