@classcad/mcp 0.1.4 → 0.2.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.
Files changed (42) hide show
  1. package/README.md +20 -2
  2. package/dist/auth.d.ts +7 -0
  3. package/dist/auth.js +2 -2
  4. package/dist/auth.js.map +1 -1
  5. package/dist/client.js +27 -0
  6. package/dist/client.js.map +1 -1
  7. package/dist/mcp-server.d.ts +1 -1
  8. package/dist/mcp-server.js +9 -3
  9. package/dist/mcp-server.js.map +1 -1
  10. package/dist/queue.d.ts +7 -0
  11. package/dist/queue.js +22 -9
  12. package/dist/queue.js.map +1 -1
  13. package/dist/tools/auth.js +1 -1
  14. package/dist/tools/auth.js.map +1 -1
  15. package/dist/tools/docs.js +1 -0
  16. package/dist/tools/docs.js.map +1 -1
  17. package/dist/tools/lifecycle.js +41 -19
  18. package/dist/tools/lifecycle.js.map +1 -1
  19. package/dist/tools/viewer.d.ts +6 -0
  20. package/dist/tools/viewer.js +110 -0
  21. package/dist/tools/viewer.js.map +1 -0
  22. package/dist/viewer/scene.d.ts +60 -0
  23. package/dist/viewer/scene.js +271 -0
  24. package/dist/viewer/scene.js.map +1 -0
  25. package/dist/viewer/server.d.ts +27 -0
  26. package/dist/viewer/server.js +229 -0
  27. package/dist/viewer/server.js.map +1 -0
  28. package/dist/viewer-page/cube.svg +15 -0
  29. package/dist/viewer-page/fonts/LICENSE.txt +3 -0
  30. package/dist/viewer-page/fonts/jetbrains-mono-latin.woff2 +0 -0
  31. package/dist/viewer-page/index.html +135 -0
  32. package/dist/viewer-page/vendor/LICENSE.txt +21 -0
  33. package/dist/viewer-page/vendor/VERSION.txt +1 -0
  34. package/dist/viewer-page/vendor/addons/controls/OrbitControls.js +1963 -0
  35. package/dist/viewer-page/vendor/addons/lines/LineMaterial.js +726 -0
  36. package/dist/viewer-page/vendor/addons/lines/LineSegments2.js +434 -0
  37. package/dist/viewer-page/vendor/addons/lines/LineSegmentsGeometry.js +298 -0
  38. package/dist/viewer-page/vendor/three.core.min.js +6 -0
  39. package/dist/viewer-page/vendor/three.module.min.js +6 -0
  40. package/dist/viewer-page/viewer.css +717 -0
  41. package/dist/viewer-page/viewer.js +530 -0
  42. package/package.json +3 -2
@@ -0,0 +1,530 @@
1
+ // The ClassCAD viewer: one session's model, live.
2
+ //
3
+ // The part is drawn as the family draws it: the paper it lies on, by its lines — the
4
+ // B-rep edges and, for the view as it stands, the silhouettes of its curved faces —
5
+ // and lit a little, so a part turned by hand says which way a face looks (an even
6
+ // ground light and one lamp over the reader's left shoulder). Orthographic, Z up.
7
+ import * as THREE from 'three'
8
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
9
+ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'
10
+ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'
11
+ import { LineMaterial } from 'three/addons/lines/LineMaterial.js'
12
+
13
+ const $ = id => document.getElementById(id)
14
+ const base = location.pathname.replace(/\/+$/, '')
15
+ const app = $('app')
16
+ const stage = $('stage')
17
+ const canvas = $('canvas')
18
+
19
+ // ── the stage's colours, per theme (BRANDING.md, "The stage") ────────────────
20
+ const STAGE = {
21
+ light: { ground: '#f3f4f7', body: '#ffffff', edge: '#0f1320' },
22
+ dark: { ground: '#1b1e23', body: '#2b2f36', edge: '#dcdee3' },
23
+ }
24
+ const theme = () => (document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
25
+
26
+ // ── three ───────────────────────────────────────────────────────────────────
27
+ const dpr = Math.min(2, window.devicePixelRatio || 1)
28
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' })
29
+ renderer.setPixelRatio(dpr)
30
+ renderer.setClearColor(0x000000, 0)
31
+ const scene = new THREE.Scene()
32
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1e5, 1e5)
33
+ camera.up.set(0, 0, 1)
34
+ const holder = new THREE.Group()
35
+ scene.add(holder)
36
+
37
+ // A face that looks at the reader is paper; one that turns away falls off to a light
38
+ // grey, and no further. Shaded in display space, so the paper stays the paper's colour.
39
+ const faceUniforms = { uPaper: { value: new THREE.Color() }, uGround: { value: 0.78 }, uLamp: { value: 0.3 } }
40
+ const faceMaterial = opacity =>
41
+ new THREE.ShaderMaterial({
42
+ uniforms: { ...faceUniforms, uOpacity: { value: opacity } },
43
+ vertexShader: /* glsl */ `
44
+ attribute vec4 aColor;
45
+ varying vec3 vNormal; varying vec4 vColor;
46
+ void main() {
47
+ vNormal = normalize(normalMatrix * normal);
48
+ vColor = aColor;
49
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
50
+ }`,
51
+ fragmentShader: /* glsl */ `
52
+ uniform vec3 uPaper; uniform float uGround; uniform float uLamp; uniform float uOpacity;
53
+ varying vec3 vNormal; varying vec4 vColor;
54
+ const vec3 LAMP = normalize(vec3(-0.45, 0.55, 0.7)); // over the reader's left shoulder, in view space
55
+ void main() {
56
+ vec3 n = normalize(vNormal);
57
+ if (!gl_FrontFacing) n = -n;
58
+ vec3 base = mix(uPaper, vColor.rgb, vColor.a);
59
+ float light = min(1.0, uGround + uLamp * max(dot(n, LAMP), 0.0) * 1.25);
60
+ gl_FragColor = vec4(base * light, uOpacity);
61
+ }`,
62
+ side: THREE.DoubleSide,
63
+ transparent: opacity < 1,
64
+ depthWrite: opacity >= 1,
65
+ polygonOffset: true,
66
+ polygonOffsetFactor: 1,
67
+ polygonOffsetUnits: 1,
68
+ })
69
+ const edgeMaterial = new LineMaterial({ color: 0x0f1320, linewidth: 1.4, transparent: true })
70
+
71
+ const controls = new OrbitControls(camera, canvas)
72
+ controls.enableDamping = false
73
+ controls.zoomToCursor = true
74
+ controls.screenSpacePanning = true
75
+ controls.rotateSpeed = 0.9
76
+
77
+ let model = null // { bodies: [{ mesh, edges, sil, cands, inverse }], bounds }
78
+ let showEdges = true
79
+ let userMoved = false
80
+ let needsRender = true
81
+ let silDirty = true
82
+ controls.addEventListener('start', () => (userMoved = true))
83
+ controls.addEventListener('change', () => {
84
+ needsRender = true
85
+ silDirty = true
86
+ })
87
+
88
+ const fat = positions => {
89
+ const g = new LineSegmentsGeometry()
90
+ g.setPositions(positions.length ? positions : [0, 0, 0, 0, 0, 0])
91
+ const l = new LineSegments2(g, edgeMaterial)
92
+ l.frustumCulled = false
93
+ l.renderOrder = 2
94
+ return l
95
+ }
96
+
97
+ // Interior edges of curved faces with the normals of both triangles beside them: a
98
+ // silhouette is where one looks at the eye and the other does not.
99
+ function silhouetteCandidates(faces) {
100
+ const out = []
101
+ for (const f of faces) {
102
+ if (f.flat) continue
103
+ const v = f.p
104
+ const idx = f.i
105
+ const weld = new Map()
106
+ const remap = new Uint32Array(v.length / 3)
107
+ for (let i = 0; i < v.length / 3; i++) {
108
+ const k = `${Math.round(v[3 * i] * 1000)},${Math.round(v[3 * i + 1] * 1000)},${Math.round(v[3 * i + 2] * 1000)}`
109
+ if (!weld.has(k)) weld.set(k, i)
110
+ remap[i] = weld.get(k)
111
+ }
112
+ const edges = new Map()
113
+ const tn = []
114
+ for (let t = 0; t < idx.length / 3; t++) {
115
+ const a = remap[idx[3 * t]], b = remap[idx[3 * t + 1]], c = remap[idx[3 * t + 2]]
116
+ const ux = v[3 * b] - v[3 * a], uy = v[3 * b + 1] - v[3 * a + 1], uz = v[3 * b + 2] - v[3 * a + 2]
117
+ const wx = v[3 * c] - v[3 * a], wy = v[3 * c + 1] - v[3 * a + 1], wz = v[3 * c + 2] - v[3 * a + 2]
118
+ let nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx
119
+ const len = Math.hypot(nx, ny, nz)
120
+ if (len < 1e-9) { tn.push(null); continue }
121
+ nx /= len; ny /= len; nz /= len
122
+ tn.push([nx, ny, nz])
123
+ for (const [p, q] of [[a, b], [b, c], [c, a]]) {
124
+ const k = p < q ? p * 4294967296 + q : q * 4294967296 + p
125
+ const e = edges.get(k)
126
+ if (e) e.t2 = t
127
+ else edges.set(k, { p, q, t1: t, t2: -1 })
128
+ }
129
+ }
130
+ for (const e of edges.values()) {
131
+ if (e.t2 < 0 || !tn[e.t1] || !tn[e.t2]) continue
132
+ const n1 = tn[e.t1], n2 = tn[e.t2]
133
+ if (n1[0] * n2[0] + n1[1] * n2[1] + n1[2] * n2[2] > 0.99999) continue
134
+ out.push(v[3 * e.p], v[3 * e.p + 1], v[3 * e.p + 2], v[3 * e.q], v[3 * e.q + 1], v[3 * e.q + 2], n1[0], n1[1], n1[2], n2[0], n2[1], n2[2])
135
+ }
136
+ }
137
+ return new Float32Array(out)
138
+ }
139
+
140
+ function buildBody(body) {
141
+ let nv = 0, ni = 0
142
+ for (const f of body.faces) { nv += f.p.length / 3; ni += f.i.length }
143
+ const pos = new Float32Array(nv * 3), nor = new Float32Array(nv * 3), col = new Float32Array(nv * 4)
144
+ const index = new Uint32Array(ni)
145
+ let vo = 0, io = 0, haveNormals = true
146
+ for (const f of body.faces) {
147
+ const n = f.p.length / 3
148
+ pos.set(f.p, vo * 3)
149
+ if (f.n) nor.set(f.n, vo * 3)
150
+ else haveNormals = false
151
+ const c = f.c ?? body.color // a colour of its own, or none: the paper's
152
+ for (let i = 0; i < n; i++) {
153
+ if (c) col.set([c[0] / 255, c[1] / 255, c[2] / 255, 1], (vo + i) * 4)
154
+ }
155
+ for (let i = 0; i < f.i.length; i++) index[io + i] = f.i[i] + vo
156
+ vo += n
157
+ io += f.i.length
158
+ }
159
+ const g = new THREE.BufferGeometry()
160
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
161
+ g.setAttribute('normal', new THREE.BufferAttribute(nor, 3))
162
+ g.setAttribute('aColor', new THREE.BufferAttribute(col, 4))
163
+ g.setIndex(new THREE.BufferAttribute(index, 1))
164
+ if (!haveNormals) g.computeVertexNormals()
165
+ const segs = []
166
+ for (const p of body.edges) for (let i = 0; i + 5 < p.length; i += 3) segs.push(p[i], p[i + 1], p[i + 2], p[i + 3], p[i + 4], p[i + 5])
167
+ return { geometry: g, segments: segs, cands: silhouetteCandidates(body.faces), opacity: body.opacity ?? 1 }
168
+ }
169
+
170
+ function setModel(data) {
171
+ for (const o of [...holder.children]) {
172
+ holder.remove(o)
173
+ o.traverse?.(x => x.geometry?.dispose?.())
174
+ }
175
+ model = null
176
+ if (!data.bodies.length) return
177
+ const built = data.bodies.map(buildBody)
178
+ const parts = []
179
+ for (const pl of data.placements) {
180
+ const b = built[pl.body]
181
+ const group = new THREE.Group()
182
+ const m = new THREE.Matrix4().set(...pl.m)
183
+ group.matrixAutoUpdate = false
184
+ group.matrix.copy(m)
185
+ const mesh = new THREE.Mesh(b.geometry, faceMaterial(b.opacity))
186
+ const edges = fat(b.segments)
187
+ const sil = fat([])
188
+ group.add(mesh, edges, sil)
189
+ holder.add(group)
190
+ parts.push({ group, edges, sil, cands: b.cands, normalToLocal: new THREE.Matrix3().setFromMatrix4(m).invert() })
191
+ }
192
+ holder.updateMatrixWorld(true)
193
+ const box = data.bounds ? new THREE.Box3(new THREE.Vector3(...data.bounds.min), new THREE.Vector3(...data.bounds.max)) : new THREE.Box3().setFromObject(holder)
194
+ model = { parts, box }
195
+ silDirty = true
196
+ needsRender = true
197
+ }
198
+
199
+ // Silhouettes for the view as it stands. On a heavy model, once it rests.
200
+ const HEAVY = 400_000
201
+ let silTimer = 0
202
+ function updateSilhouettes(force) {
203
+ if (!model || !showEdges) return
204
+ const total = model.parts.reduce((s, p) => s + p.cands.length, 0)
205
+ if (!force && total > HEAVY) {
206
+ clearTimeout(silTimer)
207
+ silTimer = setTimeout(() => { updateSilhouettes(true); needsRender = true }, 140)
208
+ return
209
+ }
210
+ const dir = new THREE.Vector3()
211
+ camera.getWorldDirection(dir).negate() // toward the eye
212
+ for (const p of model.parts) {
213
+ const d = dir.clone().applyMatrix3(p.normalToLocal)
214
+ const c = p.cands
215
+ const out = []
216
+ for (let i = 0; i < c.length; i += 12) {
217
+ const d1 = c[i + 6] * d.x + c[i + 7] * d.y + c[i + 8] * d.z
218
+ const d2 = c[i + 9] * d.x + c[i + 10] * d.y + c[i + 11] * d.z
219
+ if (d1 * d2 < 0) out.push(c[i], c[i + 1], c[i + 2], c[i + 3], c[i + 4], c[i + 5])
220
+ }
221
+ p.sil.geometry.dispose()
222
+ p.sil.geometry = new LineSegmentsGeometry()
223
+ p.sil.geometry.setPositions(out.length ? out : [0, 0, 0, 0, 0, 0])
224
+ p.sil.visible = showEdges && out.length > 0
225
+ }
226
+ }
227
+
228
+ // ── the view ────────────────────────────────────────────────────────────────
229
+ const VIEWS = { iso: [38, 35.2644], front: [0, 0], top: [0, 90], right: [90, 0] }
230
+ let size = 100 // how much of the world the view is tall, mm
231
+
232
+ function frame(az, el, animate = true) {
233
+ const box = model?.box
234
+ const center = box ? box.getCenter(new THREE.Vector3()) : new THREE.Vector3()
235
+ const radius = box ? Math.max(box.getSize(new THREE.Vector3()).length() / 2, 1e-3) : 50
236
+ const a = THREE.MathUtils.degToRad(az)
237
+ const e = THREE.MathUtils.degToRad(Math.min(el, 89.999))
238
+ const back = new THREE.Vector3(Math.sin(a) * Math.cos(e), -Math.cos(a) * Math.cos(e), Math.sin(e))
239
+ const to = { position: center.clone().addScaledVector(back, radius * 4 + 100), target: center, size: radius * 2.5 }
240
+ const from = { position: camera.position.clone(), target: controls.target.clone(), size }
241
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
242
+ const t0 = performance.now()
243
+ const dur = animate && !reduced ? 320 : 0
244
+ const step = now => {
245
+ const k = dur ? Math.min(1, (now - t0) / dur) : 1
246
+ const s = 1 - Math.pow(1 - k, 3)
247
+ camera.position.lerpVectors(from.position, to.position, s)
248
+ controls.target.lerpVectors(from.target, to.target, s)
249
+ size = from.size + (to.size - from.size) * s
250
+ camera.zoom = 1
251
+ lens()
252
+ camera.up.set(0, 0, 1)
253
+ camera.lookAt(controls.target)
254
+ controls.update()
255
+ needsRender = true
256
+ silDirty = true
257
+ if (k < 1) requestAnimationFrame(step)
258
+ }
259
+ step(t0)
260
+ }
261
+
262
+ function lens() {
263
+ const w = stage.clientWidth || 1, h = stage.clientHeight || 1
264
+ const hh = size / 2, hw = (hh * w) / h
265
+ camera.left = -hw; camera.right = hw; camera.top = hh; camera.bottom = -hh
266
+ camera.updateProjectionMatrix()
267
+ }
268
+
269
+ function resize() {
270
+ const w = stage.clientWidth || 1, h = stage.clientHeight || 1
271
+ renderer.setSize(w, h, false)
272
+ edgeMaterial.resolution.set(w, h)
273
+ lens()
274
+ needsRender = true
275
+ }
276
+ new ResizeObserver(resize).observe(stage)
277
+
278
+ // the part's three axes, as the eye sees them
279
+ const triad = { x: $('triad-x'), y: $('triad-y'), z: $('triad-z'), lx: $('triad-lx'), ly: $('triad-ly'), lz: $('triad-lz') }
280
+ function drawTriad() {
281
+ const q = camera.quaternion.clone().invert()
282
+ for (const [k, v] of [['x', new THREE.Vector3(1, 0, 0)], ['y', new THREE.Vector3(0, 1, 0)], ['z', new THREE.Vector3(0, 0, 1)]]) {
283
+ v.applyQuaternion(q)
284
+ triad[k].setAttribute('x2', (v.x * 17).toFixed(2))
285
+ triad[k].setAttribute('y2', (-v.y * 17).toFixed(2))
286
+ triad['l' + k].setAttribute('x', (v.x * 25).toFixed(2))
287
+ triad['l' + k].setAttribute('y', (-v.y * 25).toFixed(2))
288
+ triad[k].style.opacity = triad['l' + k].style.opacity = v.z < -0.01 ? 0.45 : 1
289
+ }
290
+ }
291
+
292
+ let pale = 1 // while the part rebuilds, its lines pale to 45%
293
+ function loop() {
294
+ requestAnimationFrame(loop)
295
+ if (pale < 1) {
296
+ pale = Math.min(1, pale + 0.035)
297
+ edgeMaterial.opacity = pale
298
+ needsRender = true
299
+ }
300
+ if (!needsRender) return
301
+ needsRender = false
302
+ if (silDirty) {
303
+ silDirty = false
304
+ updateSilhouettes(false)
305
+ }
306
+ drawTriad()
307
+ renderer.render(scene, camera)
308
+ }
309
+
310
+ function applyTheme() {
311
+ const c = STAGE[theme()]
312
+ faceUniforms.uPaper.value.set(c.body)
313
+ // ShaderMaterial writes display values: undo three's conversion of the hex to linear
314
+ faceUniforms.uPaper.value.convertLinearToSRGB()
315
+ edgeMaterial.color.set(c.edge)
316
+ $('theme').setAttribute('aria-pressed', String(theme() === 'dark'))
317
+ needsRender = true
318
+ }
319
+
320
+ // ── the chrome ──────────────────────────────────────────────────────────────
321
+ const fmt = n => n.toLocaleString('en-US')
322
+ const mm = v => (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(1)).replace(/\.0$/, '')
323
+ const call = (kind, sceneKind) => `${sceneKind === 'ASSEMBLY' ? 'assembly' : 'part'}.${kind.charAt(0).toLowerCase()}${kind.slice(1)}`
324
+ const el = (tag, cls, text) => {
325
+ const n = document.createElement(tag)
326
+ if (cls) n.className = cls
327
+ if (text != null) n.textContent = text
328
+ return n
329
+ }
330
+
331
+ function setChrome(payload) {
332
+ const s = payload.scene
333
+ const name = s.name || (s.kind === 'EMPTY' ? 'No model' : 'Untitled')
334
+ document.title = s.kind === 'EMPTY' ? 'ClassCAD' : `${name} · ClassCAD`
335
+ $('kind').textContent = s.kind === 'ASSEMBLY' ? 'Assembly' : 'Part'
336
+ $('name').textContent = name
337
+ $('hud').textContent = s.kind === 'EMPTY' ? '' : `${s.kind === 'ASSEMBLY' ? 'Assembly' : 'Part'} · ${name}`
338
+
339
+ const features = $('features')
340
+ features.replaceChildren()
341
+ $('feature-count').textContent = s.features.length || ''
342
+ if (!s.features.length) features.append(el('p', 'empty', s.kind === 'ASSEMBLY' ? 'An assembly has no features of its own.' : 'No features yet. What the agent builds is listed here.'))
343
+ for (const f of s.features) {
344
+ const row = el('li', 'row')
345
+ row.append(el('span', 'row__no', String(f.no).padStart(2, '0')), el('span', 'row__name', f.name), el('span', 'call', call(f.kind, s.kind)))
346
+ features.append(row)
347
+ }
348
+
349
+ const bodies = $('bodies')
350
+ bodies.replaceChildren()
351
+ $('body-count').textContent = s.placements.length || ''
352
+ if (!s.placements.length) bodies.append(el('p', 'empty', 'No bodies yet. A feature that makes one puts it here.'))
353
+ for (const pl of s.placements) {
354
+ const b = s.bodies[pl.body]
355
+ const row = el('li', 'row')
356
+ const sw = el('span', 'row__swatch')
357
+ sw.style.background = b.color ? `rgb(${b.color.join(',')})` : STAGE[theme()].body
358
+ sw.dataset.paper = b.color ? '' : '1'
359
+ row.append(sw, el('span', 'row__name', b.name))
360
+ bodies.append(row)
361
+ }
362
+
363
+ $('s-bodies').textContent = fmt(s.stats.bodies)
364
+ $('s-faces').textContent = fmt(s.stats.faces)
365
+ $('s-tris').textContent = fmt(s.stats.triangles)
366
+ const extent = $('s-size')
367
+ extent.replaceChildren()
368
+ if (s.bounds) {
369
+ const d = s.bounds.max.map((v, i) => v - s.bounds.min[i])
370
+ extent.append(`${mm(d[0])} × ${mm(d[1])} × ${mm(d[2])} `, el('span', 'unit', 'mm'))
371
+ } else extent.textContent = '–'
372
+ $('s-host').textContent = payload.host || '–'
373
+ $('engine').textContent = `ClassCAD · ${payload.engine}`
374
+ }
375
+
376
+ const clock = () => new Date().toLocaleTimeString('en-GB')
377
+ let version = -1
378
+ let loading = null
379
+ async function load() {
380
+ if (loading) return loading
381
+ loading = (async () => {
382
+ $('dot').dataset.state = 'working'
383
+ try {
384
+ const res = await fetch(`${base}/scene`, { cache: 'no-store' })
385
+ if (res.status === 404) return ended()
386
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
387
+ const payload = await res.json()
388
+ if (payload.version === version) return
389
+ const first = version < 0
390
+ version = payload.version
391
+ const wasEmpty = !model
392
+ setModel(payload.scene)
393
+ setChrome(payload)
394
+ app.dataset.state = payload.scene.bodies.length ? 'ready' : 'empty'
395
+ $('doing').textContent = payload.scene.bodies.length ? `live · updated ${clock()}` : 'live · waiting for the first body'
396
+ if (model && (first || wasEmpty || !userMoved)) frame(...VIEWS.iso, !first && !wasEmpty)
397
+ if (!first && model) pale = 0.45
398
+ needsRender = true
399
+ } finally {
400
+ $('dot').dataset.state = ''
401
+ loading = null
402
+ }
403
+ })()
404
+ return loading
405
+ }
406
+
407
+ function ended(text) {
408
+ $('scrim').hidden = false
409
+ if (text) $('scrim-text').textContent = text
410
+ $('dot').dataset.state = 'off'
411
+ $('doing').textContent = 'the session has ended'
412
+ if (app.dataset.state === 'loading') app.dataset.state = 'empty'
413
+ events?.close()
414
+ // nothing is left to ask for a file
415
+ closeMenu()
416
+ exportButton.disabled = true
417
+ }
418
+
419
+ let events = null
420
+ let misses = 0
421
+ function listen() {
422
+ events = new EventSource(`${base}/events`)
423
+ events.addEventListener('hello', () => {
424
+ misses = 0
425
+ $('scrim').hidden = true
426
+ load().catch(() => {})
427
+ })
428
+ events.addEventListener('scene', () => load().catch(() => {}))
429
+ events.addEventListener('closed', () => ended())
430
+ events.onerror = () => {
431
+ // the browser retries by itself; after a few misses the session is gone
432
+ if (++misses >= 3) ended('The ClassCAD MCP behind this view is no longer running. What you see is its last model.')
433
+ }
434
+ }
435
+
436
+ // theme: follows the system until its reader picks the other one
437
+ $('theme').addEventListener('click', () => {
438
+ const next = theme() === 'dark' ? 'light' : 'dark'
439
+ const system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
440
+ try {
441
+ if (next === system) localStorage.removeItem('classcad-mcp:theme')
442
+ else localStorage.setItem('classcad-mcp:theme', next)
443
+ } catch {}
444
+ document.documentElement.dataset.theme = next
445
+ applyTheme()
446
+ for (const sw of document.querySelectorAll('.row__swatch[data-paper="1"]')) sw.style.background = STAGE[next].body
447
+ })
448
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
449
+ let pick = null
450
+ try { pick = localStorage.getItem('classcad-mcp:theme') } catch {}
451
+ if (pick) return
452
+ document.documentElement.dataset.theme = e.matches ? 'dark' : 'light'
453
+ applyTheme()
454
+ })
455
+
456
+ // the view's dock
457
+ for (const b of document.querySelectorAll('[data-view]')) {
458
+ b.addEventListener('click', () => {
459
+ userMoved = true
460
+ frame(...VIEWS[b.dataset.view])
461
+ })
462
+ }
463
+ $('fit').addEventListener('click', () => {
464
+ userMoved = false
465
+ frame(...VIEWS.iso)
466
+ })
467
+ $('edges').addEventListener('click', e => {
468
+ showEdges = !showEdges
469
+ e.currentTarget.setAttribute('aria-pressed', String(showEdges))
470
+ for (const p of model?.parts ?? []) p.edges.visible = showEdges
471
+ for (const p of model?.parts ?? []) p.sil.visible = showEdges
472
+ silDirty = true
473
+ needsRender = true
474
+ })
475
+
476
+ // export: the model as a file
477
+ const exportButton = $('export-button')
478
+ const exportList = document.querySelector('#export .menu__list')
479
+ const closeMenu = () => {
480
+ exportList.hidden = true
481
+ exportButton.setAttribute('aria-expanded', 'false')
482
+ }
483
+ exportButton.addEventListener('click', e => {
484
+ e.stopPropagation()
485
+ exportList.hidden = !exportList.hidden
486
+ exportButton.setAttribute('aria-expanded', String(!exportList.hidden))
487
+ })
488
+ document.addEventListener('click', closeMenu)
489
+ document.addEventListener('keydown', e => e.key === 'Escape' && closeMenu())
490
+ for (const a of document.querySelectorAll('[data-export]')) {
491
+ a.addEventListener('click', e => {
492
+ e.preventDefault()
493
+ const name = ($('name').textContent || 'model').trim().replace(/[^A-Za-z0-9._ -]/g, '_') || 'model'
494
+ const link = document.createElement('a')
495
+ link.href = `${base}/export/${encodeURIComponent(name)}.${a.dataset.export}`
496
+ link.download = `${name}.${a.dataset.export}`
497
+ document.body.append(link)
498
+ link.click()
499
+ link.remove()
500
+ closeMenu()
501
+ })
502
+ }
503
+
504
+ // tooltips: dark chrome, after a moment
505
+ const tip = $('tip')
506
+ let tipTimer = 0
507
+ for (const t of document.querySelectorAll('[data-tip]')) {
508
+ t.addEventListener('pointerenter', () => {
509
+ clearTimeout(tipTimer)
510
+ tipTimer = setTimeout(() => {
511
+ tip.textContent = t.dataset.tip
512
+ tip.hidden = false
513
+ const r = t.getBoundingClientRect()
514
+ const w = tip.offsetWidth
515
+ tip.style.left = `${Math.max(8, r.left - w - 8)}px`
516
+ tip.style.top = `${r.top + r.height / 2 - tip.offsetHeight / 2}px`
517
+ }, 350)
518
+ })
519
+ t.addEventListener('pointerleave', () => {
520
+ clearTimeout(tipTimer)
521
+ tip.hidden = true
522
+ })
523
+ }
524
+
525
+ applyTheme()
526
+ resize()
527
+ loop()
528
+ listen()
529
+ load().catch(() => ended('This view could not reach the ClassCAD MCP. Ask your agent for the current link.'))
530
+ window.__viewer = { get version() { return version }, get model() { return model }, camera }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classcad/mcp",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "mcpName": "io.github.awv-informatik/classcad",
5
5
  "description": "ClassCAD MCP server: build, verify and export 3D CAD parts and assemblies with the ClassCAD engine, running locally",
6
6
  "homepage": "https://classcad.ai",
@@ -60,6 +60,7 @@
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",
62
62
  "@types/ws": "^8.5.13",
63
- "typescript": "^5.6.0"
63
+ "typescript": "^5.6.0",
64
+ "three": "^0.185.1"
64
65
  }
65
66
  }