@classcad/mcp 0.1.5 → 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.
- package/README.md +20 -2
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +2 -2
- package/dist/auth.js.map +1 -1
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.js +9 -3
- package/dist/mcp-server.js.map +1 -1
- package/dist/queue.d.ts +7 -0
- package/dist/queue.js +22 -9
- package/dist/queue.js.map +1 -1
- package/dist/tools/auth.js +1 -1
- package/dist/tools/auth.js.map +1 -1
- package/dist/tools/lifecycle.js +40 -18
- package/dist/tools/lifecycle.js.map +1 -1
- package/dist/tools/viewer.d.ts +6 -0
- package/dist/tools/viewer.js +110 -0
- package/dist/tools/viewer.js.map +1 -0
- package/dist/viewer/scene.d.ts +60 -0
- package/dist/viewer/scene.js +271 -0
- package/dist/viewer/scene.js.map +1 -0
- package/dist/viewer/server.d.ts +27 -0
- package/dist/viewer/server.js +229 -0
- package/dist/viewer/server.js.map +1 -0
- package/dist/viewer-page/cube.svg +15 -0
- package/dist/viewer-page/fonts/LICENSE.txt +3 -0
- package/dist/viewer-page/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/viewer-page/index.html +135 -0
- package/dist/viewer-page/vendor/LICENSE.txt +21 -0
- package/dist/viewer-page/vendor/VERSION.txt +1 -0
- package/dist/viewer-page/vendor/addons/controls/OrbitControls.js +1963 -0
- package/dist/viewer-page/vendor/addons/lines/LineMaterial.js +726 -0
- package/dist/viewer-page/vendor/addons/lines/LineSegments2.js +434 -0
- package/dist/viewer-page/vendor/addons/lines/LineSegmentsGeometry.js +298 -0
- package/dist/viewer-page/vendor/three.core.min.js +6 -0
- package/dist/viewer-page/vendor/three.module.min.js +6 -0
- package/dist/viewer-page/viewer.css +717 -0
- package/dist/viewer-page/viewer.js +530 -0
- 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.
|
|
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
|
}
|