SLOPSHOPPER

3dgs-view

Preview 3D Gaussian splats (.ply, .spz, .splat, .ksplat, .sog) in a Claude Code pane: orbit with the mouse, rendered by Spark in headless Chrome

panebandguardcommandpromptprocess
★ 2v0.1.0MITupdated 2026-10-13parodiniccolo/claude-3dgs-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 3dgs-view
│ ┃ splat ✕ › fix the failing auth test and add an audit log call │ ┃ Usage: /splat │ ┃ <file.ply|.splat|.spz|.ksplat|.sog> ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /splat │ ⎿ 3dgs-view: Usage: /splat <file.ply|.splat|.spz|.ksplat|.sog> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · splat
Usage: /splat <file.ply|.splat|.spz|.ksplat|.sog>
README

Claude 3DGS View

A Claude Code mod that previews 3D Gaussian splats in a pane. Run /splat scene.ply and orbit the scene with the mouse without leaving the terminal.

License: MIT

Claude 3DGS View in action

Install

Inside Claude Code, run:

/plugin marketplace add parodiniccolo/claude-3dgs-view
/plugin install 3dgs-view
/reload-plugins

Then /splat path/to/scene.ply. A pane opens with the first frame a few seconds later.

claude plugin marketplace add parodiniccolo/claude-3dgs-view
claude plugin install 3dgs-view@claude-3dgs-view

Then run /reload-plugins inside a session, or start a new one.

What You See

◆ scene.ply  969 MB · 3.9M splats  ● ready
┌──────────────────────────────────────────┐
│                                          │
│              (the splat scene)           │
│                                          │
└──────────────────────────────────────────┘
orbit · az 210° · el 20° · dist 0.50      drag orbits · right-drag pans · wheel zooms · esc to prompt
a: ◀ orbit   d: orbit ▶   q: ▲ up   e: ▼ down   w: zoom in   s: zoom out   m: mode: orbit   r: reset
o: open in browser
  • Two modes, switched with m. Orbit circles the scene. Look around stands at one spot and turns 360° in place, which suits room scans; there w/s and the wheel walk forward and back.
  • Mouse. Drag to orbit (or look), right-drag to pan (or slide), wheel to zoom (or walk). While dragging you get quick low-resolution frames, then a sharp one when you let go.
  • Keys work while the pane has focus (it opens focused; ctrl+x then tab gets it back): a/d orbit or turn, q/e tilt, w/s zoom or walk, m switches mode, r resets the camera.
  • Browser. o opens the interactive viewer at the same viewpoint and mode, with the same keys and mouse controls.
  • It offers itself. Mention a splat file in a prompt, or let Claude touch one with a tool, and a line above the prompt asks whether to preview it. Press 1 for yes, 2 for no.
  • Formats: .ply (3DGS and compressed), .spz, .splat, .ksplat, .sog, whatever Spark loads.

How It Works

Claude 3DGS View is a mod. Terminals can't run WebGL, so the rendering happens in a browser you never see:

  1. /splat <file> starts a small Python server on 127.0.0.1 (hooks/serve.py) that serves the splat file and a viewer page (hooks/viewer.html) built on Spark and three.js.
  2. The server launches headless Chrome on that page once. The page loads the splat, frames it from the 5th–95th percentile of splat centers (floaters would stretch a plain bounding box), renders, and POSTs a PNG back.
  3. The pane draws that PNG with Claude Code's Image element. The terminal reads the file itself, so no pixels pass through the mod. A transparent Client region over the picture catches mouse drags.
  4. Every move is one HTTP request to the server, which hands the new camera to the already-loaded page through a long poll. A drag frame takes about 0.1 s; a sharp frame about 2 s on a 4M-splat scene.

In orbit mode the camera circles the framed center: azimuth, elevation, distance, plus a pan of the orbit target in camera space. In look-around mode it stands at a point offset from the center in world space and faces along the same azimuth and elevation. The footer is laid out by the mod, not the terminal, so the picture takes exactly the rows the keys leave.

Terminal Support

Claude Code draws real pictures in Ghostty and kitty (and the mod also trusts WezTerm). Anywhere else the pane shows the file info, a link to the interactive viewer and o to open it in the browser. There is no picture in Terminal.app, iTerm2, VS Code or inside tmux: character-cell fallbacks were tried and are not worth looking at for a photographic scene.

TerminalPane
Ghostty, kitty, WezTermPicture, mouse and keys
Everything elseFile info and the browser link

Security

The mod runs local processes on your behalf. In full:

  • A local HTTP server (python3 hooks/serve.py) bound to 127.0.0.1 on a random port, serving the splat file and the viewer page to that machine only. It stops when the session ends or the mod reloads.
  • Headless Chrome, found at /Applications/Google Chrome.app, google-chrome or chromium on PATH, or $CHROME_BIN, with its own throwaway profile under /tmp/claude-splat-preview/. The server kills it when it stops.
  • Network: the viewer page loads three.js from jsDelivr and Spark from sparkjs.dev. Those are the only requests that leave the machine. The splat file never does.
  • Files: it stats the paths it is given or finds in prompts and tool inputs, writes frames under /tmp/claude-splat-preview/, and runs curl against its own server and open on the viewer URL when you press o.
  • It reads HOME, TERM and TERM_PROGRAM to resolve paths and to tell whether your terminal draws pictures.

Run claude plugin validate on the repo to see every event it hooks and every call it makes.

Requirements

  • Claude Code v2.1.287 or later (mods support). Tested with v2.1.296; the mods API is early access and moves between releases.
  • macOS or Linux with python3, curl and Google Chrome or Chromium. Rendering uses the GPU through headless Chrome; a 4M-splat scene loads in about 15 s.
  • For the picture in the pane, Ghostty, kitty or WezTerm. Other terminals get the browser link.

Troubleshooting

/splat says "Unknown command". The mod isn't loaded in that session. Run /plugin and check that 3dgs-view is listed as an active mod, or /reload-plugins.

The pane stays on "rendering". Chrome didn't start or the page failed. The status line shows the page's last error. Set CHROME_BIN if Chrome isn't in the usual place. pkill -f headless=new clears a stuck instance.

The first frame is a blur or a wall. The automatic framing put the camera outside a room scan. Press w a couple of times to move inside, or r to reset.

No picture, only a link. Your terminal doesn't draw images. See Terminal Support.

It offers to preview a .ply mesh. The mod can't tell a mesh from a splat by name. Press 2.

Development

git clone https://github.com/parodiniccolo/claude-3dgs-view
cd claude-3dgs-view

# Load it for one session without installing
claude --plugin-dir .

# Check it and run the tests
claude plugin validate .
claude plugin test .

Claude Code writes the API types into .claude-plugin/types/ the first time it loads the mod, and tsc -p . type-checks it from then on. The viewer page and server can be exercised on their own: python3 hooks/serve.py hooks/viewer.html scene.spz /tmp/shot.png prints a port, and curl "http://127.0.0.1:<port>/snap?az=30&el=20&d=1.6" renders a frame to /tmp/shot.png.

License

The code is MIT. See LICENSE.

The splat in the demo is Ceramic Studio by Victor Saliba, licensed under CC BY-NC 4.0: credit the author, no commercial use. It is not part of this repository.

Source 3 files
hooks/register.tsx 272 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register } from 'claude-code'
3
4import type { View } from '../types'
5import { pack, walk, type PadMessage } from './pad'
6
7const PANE = 'splat'
8const TMP = '/tmp/claude-splat-preview'
9const USAGE = 'Usage: /splat <file.ply|.splat|.spz|.ksplat|.sog>'
10const view = atom({ plugin: '3dgs-view', key: 'view' } as const, null as View | null)
11const offer = atom({ plugin: '3dgs-view', key: 'offer' } as const, null as string | null)
12const SPLAT_PATH = /[^\s"'`()[\]]+\.(?:ply|splat|spz|ksplat|sog)\b/gi
13// ponytail: module-level, so a reload forgets what was declined; good enough
14const seen = new Set<string>()
15
16// ponytail: module-level, what the pane last drew at; a reload starts from 80x25 until the next draw
17let grid = { columns: 80, rows: 25 }
18// ponytail: no capability query in the API, so the terminal's name says whether it draws images
19const drawsImages = (program: string, term: string) => program === 'ghostty' || program === 'WezTerm' || term === 'xterm-kitty'
20
21// ponytail: module-level, one server per session; a reload drops the handle and the engine kills the child
22let server: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | undefined
23
24const resolve = async ($: EngineInterface, arg: string) => {
25  if (arg.startsWith('/')) return arg
26  if (arg.startsWith('~/')) return ((await $.env.get('HOME')) ?? '') + arg.slice(1)
27  return `${await $.session.cwd()}/${arg}`
28}
29
30// Any splat path in `text` that exists and was not offered yet becomes the band's offer.
31const detect = async ($: EngineInterface, text: string) => {
32  for (const hit of text.match(SPLAT_PATH) ?? []) {
33    const file = await resolve($, hit)
34    if (seen.has(file) || (await read($, view))?.file === file) continue
35    const stat = await $.fs.stat(file).catch(() => undefined)
36    if (stat?.kind !== 'file') continue
37    seen.add(file)
38    await update($, offer, () => file)
39    return
40  }
41}
42
43const patch = ($: EngineInterface, fn: (v: View) => Partial<View>) =>
44  update($, view, v => (v ? { ...v, ...fn(v) } : v))
45
46type Camera = { az: number; el: number; d: number; px: number; py: number; pz: number; mode: View['mode'] }
47const STEP = 0.08 // a w/s step in look mode, in units of the scene radius
48
49const camQuery = (at: Camera) => `az=${at.az}&el=${at.el}&d=${at.d.toFixed(2)}&px=${at.px}&py=${at.py}&pz=${at.pz}${at.mode === 'look' ? '&look=1' : ''}`
50
51const snap = async ($: EngineInterface, cam: Partial<Camera>, fast = false) => {
52  const v = await read($, view)
53  if (!v?.url) return
54  const mode = cam.mode ?? v.mode
55  const at: Camera = { az: cam.az ?? v.az, el: cam.el ?? v.el, d: Math.min(4, Math.max(0.1, cam.d ?? v.d)), px: cam.px ?? v.px, py: cam.py ?? v.py, pz: cam.pz ?? v.pz, mode }
56  await patch($, () => ({ ...at, status: fast ? 'dragging' : 'rendering' }))
57  // ponytail: curl is on every mac/linux box; the server owns the Chrome process
58  const ar = (grid.columns / (grid.rows * 2)).toFixed(3) // cells are about 1:2
59  await $.process.run(['curl', '-s', `${v.url}snap?${camQuery(at)}&ar=${ar}${fast ? '&fast=1' : ''}`], { timeoutMs: 5000 })
60}
61
62export const register: Register = on => {
63  on('session.start', async ($, e, next) => {
64    // a (re)load means any earlier server of ours is gone with the old module: start the pane clean
65    await update($, view, () => null)
66    await update($, offer, () => null)
67    await $.command.register({
68      name: 'splat',
69      description: 'Preview a 3D Gaussian splat file in a pane (Spark renderer)',
70    })
71    return next(e)
72  })
73
74  on('command.run', { command: 'splat' }, async ($, e) => {
75    const arg = e.args.trim()
76    if (!arg) return { text: USAGE }
77    const file = await resolve($, arg)
78    const stat = await $.fs.stat(file).catch(() => undefined)
79    if (stat?.kind !== 'file') return { text: `splat: not a file: ${file}` }
80    seen.add(file)
81    await update($, offer, () => null)
82
83    const hasImages = drawsImages((await $.env.get('TERM_PROGRAM')) ?? '', (await $.env.get('TERM')) ?? '')
84
85    await server?.return(undefined as never)
86    const shot = `${TMP}/${Date.now()}.png` // fresh name per splat: the terminal caches by path
87    const child = $.process.spawn({
88      argv: ['python3', `${$.plugin.root}/hooks/serve.py`, `${$.plugin.root}/hooks/viewer.html`, file, shot],
89    })
90    server = child
91    await update($, view, () => ({
92      file, size: stat.size, splats: 0, url: '', shot, generation: 0, az: 30, el: 20, d: 1, px: 0, py: 0, pz: 0, mode: 'orbit', hasImages, status: 'starting server',
93    }))
94
95    void (async () => {
96      try {
97        for await (const { stream, text } of child) {
98          if (stream !== 'stdout') continue
99          for (const line of text.split('\n').filter(Boolean)) {
100            if (/^\d+$/.test(line)) {
101              await patch($, () => ({ url: `http://localhost:${line}/`, status: 'loading splat' }))
102              void snap($, {})
103            } else if (line === 'SHOT') {
104              await patch($, v => ({ generation: v.generation + 1, status: 'ready' }))
105            } else if (line.startsWith('LOG N ')) {
106              await patch($, () => ({ splats: Number(line.slice(6)) }))
107            } else {
108              await patch($, () => ({ status: line.slice(4) }))
109            }
110          }
111        }
112      } catch (err) {
113        await patch($, () => ({ status: `server failed: ${String(err)}` }))
114      }
115    })()
116
117    await $.ui.open({ id: PANE, title: 'Splat', focus: true })
118    return { text: `Previewing ${file}` }
119  })
120
121  // /clear, /resume and /branch reset every $.state value but leave the server: stop it and close the pane
122  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
123    await server?.return(undefined as never)
124    server = undefined
125    await $.ui.close({ id: PANE })
126    return next(e)
127  })
128
129  on('prompt.submit', async ($, e, next) => {
130    await detect($, e.text)
131    return next(e)
132  })
133
134  on('tool.call', async ($, e, next) => {
135    await detect($, JSON.stringify(e))
136    return next(e)
137  })
138
139  on('ui.message', { element: 'pad' }, async ($, e, next) => {
140    const m = e.data as PadMessage
141    void snap($, { az: m.az, el: m.el, px: m.px, py: m.py, pz: m.pz, mode: m.mode }, m.fast)
142    return next(e)
143  })
144
145  // the wheel over the pane zooms instead of scrolling it: down zooms out, up zooms in.
146  // ponytail: a trackpad fires many small events per gesture, so 3% a row and a clamp, not 15% a tick
147  on('ui.scroll', { requestId: PANE }, async ($, e) => {
148    const v = await read($, view)
149    // look mode walks instead: wheel down steps back, wheel up steps forward
150    if (v) void snap($, v.mode === 'look' ? walk(v, -0.02 * e.by, 0) : { d: v.d * Math.pow(1.03, e.by) })
151    return {}
152  })
153
154  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155    const file = await read($, offer)
156    if (!file || e.props.hasSurvey) return next(e)
157    const { Box, Text, Button } = $.ui.resolve(e)
158    const dismiss = () => update($, offer, () => null)
159    const theirs = await next(e) // what the mods after this one draw in the band stays
160    return (
161      <Box flexDirection="column">
162        <Box>
163          <Text color="claude">◆ </Text>
164          <Text>Preview {file.split('/').pop()} in the splat pane? </Text>
165          <Button hotkey="1" plain onPress={() => void $.command.run({ command: 'splat', args: file })}>Yes</Button>
166          <Text> </Text>
167          <Button hotkey="2" plain onPress={() => void dismiss()}>No</Button>
168        </Box>
169        {theirs}
170      </Box>
171    )
172  })
173
174  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
175    const { Box, Text, Button, Link } = $.ui.resolve(e)
176    const v = await read($, view)
177    if (!v) return <Text dimColor>{USAGE}</Text>
178    const name = v.file.split('/').pop()
179    const columns = Math.min(e.viewport?.columns ?? 80, 160) - 2
180    const isBusy = v.status !== 'ready'
181    const isLook = v.mode === 'look'
182    // orbit: a/d circle the scene; look: they turn the head, which runs the other way round
183    const turn = isLook ? 1 : -1
184    const statusColor = v.status.includes('failed') || v.status.startsWith('ERR') ? 'error' : isBusy ? 'warning' : 'success'
185
186    // the footer is laid out here, not by the terminal, so the picture can take exactly the rows it leaves
187    // the browser opens where the pane looks, in the same mode
188    const ext = v.file.split('.').pop()?.toLowerCase() ?? ''
189    const openBrowser = () => void $.process.run(['open', `${v.url}?ext=${ext}&${camQuery(v)}`])
190    const keys: [hotkey: string, label: string, onPress: () => void][] = [
191      ['a', isLook ? '◀ turn' : '◀ orbit', () => void snap($, { az: v.az + 20 * turn })],
192      ['d', isLook ? 'turn ▶' : 'orbit ▶', () => void snap($, { az: v.az - 20 * turn })],
193      ['q', isLook ? '▲ look up' : '▲ up', () => void snap($, { el: Math.max(-85, Math.min(85, v.el - 15 * turn)) })],
194      ['e', isLook ? '▼ look down' : '▼ down', () => void snap($, { el: Math.max(-85, Math.min(85, v.el + 15 * turn)) })],
195      ['w', isLook ? 'forward' : 'zoom in', () => void snap($, isLook ? walk(v, STEP, 0) : { d: v.d * 0.7 })],
196      ['s', isLook ? 'back' : 'zoom out', () => void snap($, isLook ? walk(v, -STEP, 0) : { d: v.d / 0.7 })],
197      ['m', isLook ? 'mode: look around' : 'mode: orbit', () => void snap($, { mode: isLook ? 'orbit' : 'look', d: 1, px: 0, py: 0, pz: 0 })],
198      ['r', 'reset', () => void snap($, { az: 30, el: 20, d: 1, px: 0, py: 0, pz: 0 })],
199      ['o', 'open in browser', openBrowser],
200    ]
201    const KEY_GAP = 3
202    const lines = pack(keys, ([, label]) => label.length + 3, columns, KEY_GAP) // a plain Button draws "k: label"
203    const readout = `${isLook ? 'look around' : 'orbit'} · az ${v.az}° · el ${v.el}°${isLook ? '' : ` · dist ${v.d.toFixed(2)}`}`
204    // the longest hint that fits beside the readout, else none
205    const hint = [
206      `${isLook ? 'drag looks · right-drag slides · wheel walks' : 'drag orbits · right-drag pans · wheel zooms'} · esc to prompt`,
207      'esc to prompt',
208    ].find(h => readout.length + 2 + h.length <= columns)
209    // the pane's body less its frame, our header and status rows and the key lines
210    const rows = Math.max(8, Math.min(120, (e.viewport?.rows ?? 24) - 11 - lines.length))
211    grid = { columns, rows }
212    const header = (
213      <Box>
214        <Text color="claude" bold>◆ {name}</Text>
215        <Text dimColor>  {(v.size / 1e6).toFixed(0)} MB{v.splats ? ` · ${(v.splats / 1e6).toFixed(1)}M splats` : ''}</Text>
216        <Text>  </Text>
217        <Text color={statusColor}>● {v.status}</Text>
218      </Box>
219    )
220    const browser = <Button hotkey="o" plain onPress={openBrowser}>open in browser</Button>
221
222    // a terminal without image support gets the link, not a picture
223    if (!v.hasImages || e.surface !== 'terminal') {
224      return (
225        <Box flexDirection="column">
226          {header}
227          <Text dimColor>This terminal draws no images. Ghostty, kitty and WezTerm show the preview inline.</Text>
228          {v.url && <Link href={v.url} label="interactive viewer (orbit with the mouse)" />}
229          {v.url && <Box>{browser}</Box>}
230        </Box>
231      )
232    }
233
234    const { Image, Client } = $.ui.resolve(e)
235    const picture =
236      v.generation > 0 ? (
237        <Box width={columns} height={rows}>
238          <Image
239            key="shot"
240            source={{ file: v.shot, format: 'png', generation: v.generation }}
241            columns={columns}
242            rows={rows}
243            alt="splat preview: this terminal did not draw the image; press o for the browser"
244          />
245          <Box position="absolute" top={0} left={0}>
246            <Client key="pad" module="./pad.tsx" props={{ az: v.az, el: v.el, px: v.px, py: v.py, pz: v.pz, mode: v.mode }} width={columns} height={rows} />
247          </Box>
248        </Box>
249      ) : (
250        <Box width={columns} height={rows} borderStyle="round" borderColor="subtle" alignItems="center" justifyContent="center">
251          <Text dimColor>{v.status}…</Text>
252        </Box>
253      )
254
255    return (
256      <Box flexDirection="column">
257        {header}
258        {picture}
259        <Box width={columns} justifyContent="space-between">
260          <Text dimColor>{readout}</Text>
261          {hint && <Text dimColor>{hint}</Text>}
262        </Box>
263        {lines.map(line => (
264          <Box key={line.map(([hotkey]) => hotkey).join()} columnGap={KEY_GAP}>
265            {line.map(([hotkey, label, onPress]) => <Button key={hotkey} hotkey={hotkey} plain onPress={onPress}>{label}</Button>)}
266          </Box>
267        ))}
268      </Box>
269    )
270  })
271}
272
hooks/pad.tsx 76 lines
1import type { ClientModule } from 'claude-code'
2
3export type PadProps = { az: number; el: number; px: number; py: number; pz: number; mode: 'orbit' | 'look' }
4export type PadMessage = PadProps & { fast: boolean }
5
6const DEG_PER_COL = 1.5
7const DEG_PER_ROW = 3
8const PAN_PER_COL = 0.01 // in units of the scene radius
9const PAN_PER_ROW = 0.02
10
11const round = (n: number) => +n.toFixed(3) || 0 // no -0 in the query string
12
13/** Look mode: the eye after stepping `forward` and `right` along the ground, facing `az`, in units of the scene radius. */
14export const walk = <C extends { az: number; px: number; pz: number }>(cam: C, forward: number, right: number): C => {
15  const a = (cam.az * Math.PI) / 180 // the eye faces (-sin a, 0, -cos a); its right is (cos a, 0, -sin a)
16  return { ...cam, px: round(cam.px - forward * Math.sin(a) + right * Math.cos(a)), pz: round(cam.pz - forward * Math.cos(a) - right * Math.sin(a)) }
17}
18
19/**
20 * Camera after dragging `dx` columns and `dy` rows: the left button turns, the right one pans.
21 * Both modes drag the world with the pointer; looking out from the eye turns the opposite way to circling it.
22 */
23export const dragTo = (start: PadProps, button: 'left' | 'right', dx: number, dy: number): PadProps => {
24  const sign = start.mode === 'look' ? -1 : 1
25  if (button === 'left')
26    return { ...start, az: Math.round(start.az - sign * dx * DEG_PER_COL), el: Math.max(-85, Math.min(85, Math.round(start.el + sign * dy * DEG_PER_ROW))) }
27  // look mode pans in the world, so the spot stays put while the head turns
28  if (start.mode === 'look') return { ...walk(start, 0, -dx * PAN_PER_COL), py: round(start.py + dy * PAN_PER_ROW) }
29  return { ...start, px: round(start.px - dx * PAN_PER_COL), py: round(start.py + dy * PAN_PER_ROW) }
30}
31
32/** Greedy line breaking: `items` split into lines, `gap` cells apart, none wider than `columns` unless one item alone is. */
33export const pack = <T,>(items: T[], width: (item: T) => number, columns: number, gap: number): T[][] => {
34  const lines: T[][] = []
35  let line: T[] = []
36  let used = 0
37  for (const item of items) {
38    const w = width(item)
39    if (line.length > 0 && used + gap + w <= columns) { line.push(item); used += gap + w; continue }
40    line = [item]
41    used = w
42    lines.push(line)
43  }
44  return lines
45}
46
47// ponytail: one pad per pane, so module scope holds the latest camera the pane drew
48let latest: PadProps = { az: 0, el: 20, px: 0, py: 0, pz: 0, mode: 'orbit' }
49
50const Pad: ClientModule<PadProps, { isWired: true }> = (props, s) => {
51  latest = props
52  if (!s.state) {
53    let start: { x: number; y: number; button: 'left' | 'right'; from: PadProps } | undefined
54    let sentAt = 0
55    s.onPointer(e => {
56      const x = e.fine?.x ?? e.x
57      const y = e.fine?.y ?? e.y
58      if (e.type === 'down' && (e.button === 'left' || e.button === 'right')) start = { x, y, button: e.button, from: latest }
59      if (!start) return
60      const at = dragTo(start.from, start.button, x - start.x, y - start.y)
61      if (e.type === 'move' && Date.now() - sentAt > 120) {
62        sentAt = Date.now()
63        s.post({ ...at, fast: true } satisfies PadMessage)
64      }
65      if (e.type === 'up') {
66        start = undefined
67        s.post({ ...at, fast: false } satisfies PadMessage)
68      }
69    })
70    s.setState({ isWired: true })
71  }
72  return s.elements.Box({ width: s.columns, height: s.rows })
73}
74
75export default Pad
76
types/index.d.ts 26 lines
1export type View = {
2  file: string
3  size: number
4  splats: number
5  url: string
6  shot: string
7  generation: number
8  az: number
9  el: number
10  d: number
11  px: number
12  py: number
13  /** look mode only: px, py, pz are the eye's world offset from the scene center */
14  pz: number
15  /** orbit circles the scene; look stands at a spot, turns in place and walks */
16  mode: 'orbit' | 'look'
17  status: string
18  hasImages: boolean
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    '3dgs-view': { view: View | null; offer: string | null }
24  }
25}
26