@echomem/mcp 1.4.8 → 1.4.9

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 (36) hide show
  1. package/README.md +23 -3
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  8. package/dist/city/echo-ai-city-only.html +1104 -105
  9. package/dist/city/echo-ai-city-only.template.html +1104 -105
  10. package/dist/city/pencil-pie-generator.html +883 -0
  11. package/dist/city/pencil-webgl-landscape.html +1239 -0
  12. package/dist/city/spatial-fan-story.html +479 -0
  13. package/dist/codex-session-files.js +283 -0
  14. package/dist/codex-sync.js +7 -2
  15. package/dist/context-analysis/canonical-golden.js +47 -0
  16. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  17. package/dist/context-analysis/vendored-canonical.js +793 -0
  18. package/dist/context-analysis/workspace-report.js +1838 -0
  19. package/dist/context-metrics/calculate.js +56 -0
  20. package/dist/context-metrics/model-limits.js +26 -0
  21. package/dist/context-metrics/types.js +1 -0
  22. package/dist/forensics-10-problems.js +7 -6
  23. package/dist/forensics.js +863 -132
  24. package/dist/hud/adapters.js +8 -4
  25. package/dist/hud/metric.js +13 -4
  26. package/dist/hud/monitor.js +135 -16
  27. package/dist/hud/web.js +344 -298
  28. package/dist/index.js +7 -3
  29. package/dist/local-data-paths.js +87 -0
  30. package/dist/migrate.js +37 -29
  31. package/dist/report.js +101 -40
  32. package/dist/setup-page.js +3290 -196
  33. package/dist/setup-preview.js +245 -0
  34. package/dist/setup.js +432 -34
  35. package/package.json +5 -4
  36. package/templates/echomem-recall.md +2 -2
@@ -0,0 +1,883 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Pencil Portion — 立体铅笔饼图生成器</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --ink: #272923;
11
+ --muted: #74766e;
12
+ --paper: #f2eee4;
13
+ --paper-light: #faf7ef;
14
+ --blue: #778db8;
15
+ --blue-dark: #506485;
16
+ --rule: rgba(39, 41, 35, .18);
17
+ }
18
+
19
+ * { box-sizing: border-box; }
20
+
21
+ html, body { min-height: 100%; }
22
+
23
+ body {
24
+ margin: 0;
25
+ color: var(--ink);
26
+ background: #d9d6ce;
27
+ font-family: Georgia, "Times New Roman", "Noto Serif SC", serif;
28
+ }
29
+
30
+ button, input { font: inherit; }
31
+
32
+ .app {
33
+ width: min(1480px, 100%);
34
+ min-height: 100vh;
35
+ margin: 0 auto;
36
+ display: grid;
37
+ grid-template-columns: 360px minmax(0, 1fr);
38
+ background: var(--paper);
39
+ box-shadow: 0 0 70px rgba(39,41,35,.16);
40
+ }
41
+
42
+ .controls {
43
+ position: relative;
44
+ z-index: 2;
45
+ min-height: 100vh;
46
+ padding: 42px 34px 34px;
47
+ border-right: 1px solid rgba(39,41,35,.32);
48
+ background:
49
+ radial-gradient(circle at 18% 0%, rgba(255,255,255,.9), transparent 380px),
50
+ repeating-linear-gradient(8deg, transparent 0 10px, rgba(39,41,35,.018) 11px),
51
+ var(--paper);
52
+ }
53
+
54
+ .kicker,
55
+ .control-label,
56
+ .presets-label,
57
+ .meta,
58
+ .readout,
59
+ .action,
60
+ .preset {
61
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
62
+ text-transform: uppercase;
63
+ letter-spacing: .12em;
64
+ }
65
+
66
+ .kicker {
67
+ margin: 0 0 12px;
68
+ color: var(--blue-dark);
69
+ font-size: 10px;
70
+ font-weight: 700;
71
+ }
72
+
73
+ h1 {
74
+ margin: 0;
75
+ max-width: 260px;
76
+ font-size: 42px;
77
+ font-weight: 500;
78
+ line-height: .95;
79
+ letter-spacing: -.045em;
80
+ }
81
+
82
+ .description {
83
+ margin: 18px 0 32px;
84
+ color: var(--muted);
85
+ font-size: 13px;
86
+ line-height: 1.65;
87
+ }
88
+
89
+ .presets-label {
90
+ display: block;
91
+ margin-bottom: 10px;
92
+ color: var(--muted);
93
+ font-size: 8px;
94
+ }
95
+
96
+ .presets {
97
+ display: grid;
98
+ grid-template-columns: repeat(4, 1fr);
99
+ gap: 7px;
100
+ margin-bottom: 30px;
101
+ }
102
+
103
+ .preset,
104
+ .action {
105
+ border: 1px solid rgba(39,41,35,.38);
106
+ color: var(--ink);
107
+ background: rgba(250,247,239,.55);
108
+ cursor: pointer;
109
+ transition: background .2s ease, color .2s ease, transform .2s ease;
110
+ }
111
+
112
+ .preset {
113
+ padding: 10px 4px;
114
+ font-size: 9px;
115
+ }
116
+
117
+ .preset:hover,
118
+ .preset.active {
119
+ color: var(--paper-light);
120
+ background: var(--ink);
121
+ }
122
+
123
+ .preset:active,
124
+ .action:active { transform: translateY(1px); }
125
+
126
+ .field {
127
+ padding: 13px 0 15px;
128
+ border-top: 1px solid var(--rule);
129
+ }
130
+
131
+ .field-head {
132
+ display: flex;
133
+ justify-content: space-between;
134
+ align-items: baseline;
135
+ gap: 16px;
136
+ margin-bottom: 10px;
137
+ }
138
+
139
+ .control-label {
140
+ color: var(--muted);
141
+ font-size: 8px;
142
+ }
143
+
144
+ .readout {
145
+ color: var(--ink);
146
+ font-size: 9px;
147
+ }
148
+
149
+ input[type="range"] {
150
+ width: 100%;
151
+ height: 18px;
152
+ margin: 0;
153
+ appearance: none;
154
+ background: transparent;
155
+ cursor: ew-resize;
156
+ }
157
+
158
+ input[type="range"]::-webkit-slider-runnable-track {
159
+ height: 1px;
160
+ background: rgba(39,41,35,.48);
161
+ }
162
+
163
+ input[type="range"]::-webkit-slider-thumb {
164
+ width: 12px;
165
+ height: 12px;
166
+ margin-top: -5.5px;
167
+ appearance: none;
168
+ border: 1px solid var(--ink);
169
+ border-radius: 50%;
170
+ background: var(--paper-light);
171
+ box-shadow: 0 0 0 3px rgba(119,141,184,.16);
172
+ }
173
+
174
+ input[type="range"]::-moz-range-track {
175
+ height: 1px;
176
+ background: rgba(39,41,35,.48);
177
+ }
178
+
179
+ input[type="range"]::-moz-range-thumb {
180
+ width: 11px;
181
+ height: 11px;
182
+ border: 1px solid var(--ink);
183
+ border-radius: 50%;
184
+ background: var(--paper-light);
185
+ }
186
+
187
+ .actions {
188
+ display: grid;
189
+ grid-template-columns: 1fr 1fr;
190
+ gap: 8px;
191
+ margin-top: 26px;
192
+ }
193
+
194
+ .action {
195
+ padding: 12px 10px;
196
+ font-size: 8px;
197
+ }
198
+
199
+ .action.primary {
200
+ border-color: var(--ink);
201
+ color: var(--paper-light);
202
+ background: var(--ink);
203
+ }
204
+
205
+ .action:hover { background: #fffdf7; }
206
+ .action.primary:hover { background: var(--blue-dark); }
207
+
208
+ button:focus-visible,
209
+ input:focus-visible {
210
+ outline: 2px solid var(--blue-dark);
211
+ outline-offset: 3px;
212
+ }
213
+
214
+ .stage {
215
+ position: relative;
216
+ min-height: 100vh;
217
+ display: grid;
218
+ place-items: center;
219
+ overflow: hidden;
220
+ padding: clamp(22px, 5vw, 72px);
221
+ background:
222
+ radial-gradient(circle at 50% 30%, rgba(255,255,255,.7), transparent 48%),
223
+ #e8e4da;
224
+ }
225
+
226
+ .paper {
227
+ position: relative;
228
+ width: min(100%, 900px);
229
+ aspect-ratio: 5 / 4;
230
+ overflow: hidden;
231
+ background: var(--paper);
232
+ box-shadow: 0 26px 70px rgba(39,41,35,.19), 0 2px 4px rgba(39,41,35,.12);
233
+ }
234
+
235
+ .paper::after {
236
+ content: "";
237
+ position: absolute;
238
+ inset: 0;
239
+ pointer-events: none;
240
+ opacity: .16;
241
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.74' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.24'/%3E%3C/svg%3E");
242
+ mix-blend-mode: multiply;
243
+ }
244
+
245
+ canvas {
246
+ position: absolute;
247
+ inset: 0;
248
+ display: block;
249
+ width: 100%;
250
+ height: 100%;
251
+ }
252
+
253
+ .meta {
254
+ position: absolute;
255
+ z-index: 3;
256
+ right: 28px;
257
+ bottom: 22px;
258
+ display: flex;
259
+ gap: 18px;
260
+ color: rgba(39,41,35,.52);
261
+ font-size: 7px;
262
+ pointer-events: none;
263
+ }
264
+
265
+ @media (max-width: 900px) {
266
+ .app { grid-template-columns: 1fr; }
267
+ .controls { min-height: auto; border-right: 0; border-bottom: 1px solid rgba(39,41,35,.32); }
268
+ .stage { min-height: auto; }
269
+ }
270
+
271
+ @media (max-width: 520px) {
272
+ .controls { padding: 32px 22px; }
273
+ h1 { font-size: 36px; }
274
+ .stage { padding: 16px; }
275
+ .paper { aspect-ratio: 4 / 5; }
276
+ }
277
+ </style>
278
+ </head>
279
+ <body>
280
+ <main class="app">
281
+ <aside class="controls" aria-label="饼图绘制参数">
282
+ <p class="kicker">Pencil portion · generator</p>
283
+ <h1>立体铅笔<br>饼图生成器</h1>
284
+ <p class="description">从一个起始角和扇形角度出发,实时生成带椭圆透视、实体厚度、切面排线与虚线投影的铅笔图。</p>
285
+
286
+ <span class="presets-label">Portion presets</span>
287
+ <div class="presets" aria-label="比例预设">
288
+ <button class="preset active" type="button" data-percent="28">28%</button>
289
+ <button class="preset" type="button" data-percent="25">25%</button>
290
+ <button class="preset" type="button" data-percent="23">23%</button>
291
+ <button class="preset" type="button" data-percent="17">17%</button>
292
+ </div>
293
+
294
+ <div id="fields"></div>
295
+
296
+ <div class="actions">
297
+ <button class="action" id="resetButton" type="button">Reset</button>
298
+ <button class="action primary" id="downloadButton" type="button">Export PNG</button>
299
+ </div>
300
+ </aside>
301
+
302
+ <section class="stage" aria-label="生成结果">
303
+ <div class="paper">
304
+ <canvas id="baseDrawing" width="1000" height="800" aria-hidden="true"></canvas>
305
+ <canvas id="drawing" width="1000" height="800" aria-label="可调节的立体铅笔饼图"></canvas>
306
+ <div class="meta"><span>Solid contour</span><span>Graphite shadow</span><span id="renderStats">Ready</span></div>
307
+ </div>
308
+ </section>
309
+ </main>
310
+
311
+ <script>
312
+ const canvas = document.getElementById("drawing");
313
+ const ctx = canvas.getContext("2d");
314
+ const baseCanvas = document.getElementById("baseDrawing");
315
+ const baseCtx = baseCanvas.getContext("2d");
316
+ const fieldsRoot = document.getElementById("fields");
317
+ const renderStats = document.getElementById("renderStats");
318
+ let baseFrameReady = false;
319
+ let baseFrameKey = "";
320
+ let framePending = false;
321
+
322
+ const defaults = {
323
+ start: -90,
324
+ sweep: 100.8,
325
+ thickness: 58,
326
+ tilt: 0.48,
327
+ lift: 0,
328
+ shadowAngle: 14,
329
+ shadowLength: 154,
330
+ density: 1.15
331
+ };
332
+
333
+ const state = { ...defaults };
334
+
335
+ const fieldSpecs = [
336
+ { key: "start", label: "初始角位置", min: -180, max: 180, step: 1, unit: "°" },
337
+ { key: "sweep", label: "扇形角度", min: 5, max: 355, step: 1, unit: "°" },
338
+ { key: "thickness", label: "圆盘立体宽度", min: 4, max: 80, step: 1, unit: " px" },
339
+ { key: "tilt", label: "俯视压缩", min: .32, max: .78, step: .01, unit: "" },
340
+ { key: "lift", label: "扇形抬升", min: 0, max: 70, step: 1, unit: " px" },
341
+ { key: "shadowAngle", label: "阴影方向", min: -40, max: 80, step: 1, unit: "°" },
342
+ { key: "shadowLength", label: "石墨影长", min: 30, max: 190, step: 1, unit: " px" },
343
+ { key: "density", label: "铅笔密度", min: .3, max: 1.5, step: .05, unit: "" }
344
+ ];
345
+
346
+ function createFields() {
347
+ fieldSpecs.forEach(spec => {
348
+ const field = document.createElement("div");
349
+ field.className = "field";
350
+ field.innerHTML = `
351
+ <div class="field-head">
352
+ <label class="control-label" for="${spec.key}">${spec.label}</label>
353
+ <output class="readout" id="${spec.key}Output"></output>
354
+ </div>
355
+ <input id="${spec.key}" name="${spec.key}" type="range"
356
+ min="${spec.min}" max="${spec.max}" step="${spec.step}" value="${state[spec.key]}">
357
+ `;
358
+ fieldsRoot.append(field);
359
+ const input = field.querySelector("input");
360
+ input.addEventListener("input", () => {
361
+ state[spec.key] = Number(input.value);
362
+ setPresetState();
363
+ updateReadouts();
364
+ scheduleDraw();
365
+ });
366
+ });
367
+ updateReadouts();
368
+ }
369
+
370
+ function updateReadouts() {
371
+ fieldSpecs.forEach(spec => {
372
+ const value = state[spec.key];
373
+ const shown = spec.key === "density" || spec.key === "tilt" ? value.toFixed(2) : Math.round(value);
374
+ document.getElementById(`${spec.key}Output`).value = `${shown}${spec.unit}`;
375
+ });
376
+ }
377
+
378
+ function syncInputs() {
379
+ fieldSpecs.forEach(spec => {
380
+ document.getElementById(spec.key).value = state[spec.key];
381
+ });
382
+ updateReadouts();
383
+ }
384
+
385
+ function setPresetState(percent) {
386
+ document.querySelectorAll(".preset").forEach(button => {
387
+ const isCurrent = percent
388
+ ? Number(button.dataset.percent) === percent
389
+ : Math.abs(Number(button.dataset.percent) * 3.6 - state.sweep) < .6;
390
+ button.classList.toggle("active", isCurrent);
391
+ });
392
+ }
393
+
394
+ function mulberry32(seed) {
395
+ return function random() {
396
+ let t = seed += 0x6D2B79F5;
397
+ t = Math.imul(t ^ t >>> 15, t | 1);
398
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
399
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
400
+ };
401
+ }
402
+
403
+ function ellipsePoint(cx, cy, radius, tilt, angle, yOffset = 0) {
404
+ return {
405
+ x: cx + Math.cos(angle) * radius,
406
+ y: cy + Math.sin(angle) * radius * tilt + yOffset
407
+ };
408
+ }
409
+
410
+ function ellipsePath(g, cx, cy, radius, tilt) {
411
+ g.beginPath();
412
+ g.ellipse(cx, cy, radius, radius * tilt, 0, 0, Math.PI * 2);
413
+ }
414
+
415
+ function wedgePath(g, cx, cy, radius, tilt, start, sweep, yOffset = 0) {
416
+ const steps = Math.max(18, Math.ceil(Math.abs(sweep) / (Math.PI / 90)));
417
+ g.beginPath();
418
+ g.moveTo(cx, cy + yOffset);
419
+ for (let i = 0; i <= steps; i++) {
420
+ const angle = start + sweep * (i / steps);
421
+ const point = ellipsePoint(cx, cy, radius, tilt, angle, yOffset);
422
+ g.lineTo(point.x, point.y);
423
+ }
424
+ g.lineTo(cx, cy + yOffset);
425
+ g.closePath();
426
+ }
427
+
428
+ function pencilStroke(g, pathFn, color = "#292b25", width = 1.4, repeats = 2) {
429
+ const random = mulberry32(808 + Math.round(width * 100) + repeats * 47);
430
+ for (let i = 0; i < repeats; i++) {
431
+ g.save();
432
+ g.translate((random() - .5) * 1.25, (random() - .5) * .8);
433
+ pathFn();
434
+ g.strokeStyle = color;
435
+ g.globalAlpha = .58 / Math.max(1, repeats * .64);
436
+ g.lineWidth = width + random() * .45;
437
+ g.stroke();
438
+ g.restore();
439
+ }
440
+ }
441
+
442
+ function drawPaper(g, random) {
443
+ g.fillStyle = "#f2eee4";
444
+ g.fillRect(0, 0, canvas.width, canvas.height);
445
+ const flecks = Math.round(2300 * state.density);
446
+ const colors = ["rgba(66,61,49,.025)", "rgba(255,255,255,.055)"];
447
+ for (let pass = 0; pass < 2; pass++) {
448
+ g.beginPath();
449
+ for (let i = pass; i < flecks; i += 2) {
450
+ const size = .35 + random() * 1.15;
451
+ g.rect(random() * canvas.width, random() * canvas.height, size, size);
452
+ }
453
+ g.fillStyle = colors[pass];
454
+ g.fill();
455
+ }
456
+ }
457
+
458
+ function drawGraphiteShadow(g, cx, cy, radius, tilt, random) {
459
+ const radians = state.shadowAngle * Math.PI / 180;
460
+ const dx = Math.cos(radians) * state.shadowLength;
461
+ const dy = Math.sin(radians) * state.shadowLength;
462
+ const shadowCx = cx + dx * .46;
463
+ const shadowCy = cy + state.thickness + 12 + dy * .42;
464
+
465
+ // Barely visible graphite rubbed into the paper tooth beneath the pencil marks.
466
+ g.save();
467
+ g.filter = "blur(6px)";
468
+ g.fillStyle = "rgba(31,33,28,.045)";
469
+ g.beginPath();
470
+ g.ellipse(
471
+ shadowCx,
472
+ shadowCy,
473
+ radius * 1.08,
474
+ radius * tilt * .82,
475
+ radians * .12,
476
+ 0,
477
+ Math.PI * 2
478
+ );
479
+ g.fill();
480
+ g.restore();
481
+
482
+ // Graphite dust fills the gaps between the visible strokes, darkest near contact.
483
+ const dust = Math.round(1380 * state.density);
484
+ g.save();
485
+ const dustTones = ["rgba(35,37,32,.025)", "rgba(35,37,32,.05)", "rgba(35,37,32,.085)"];
486
+ for (let pass = 0; pass < 3; pass++) {
487
+ g.beginPath();
488
+ for (let i = pass; i < dust; i += 3) {
489
+ const ring = Math.pow(random(), .58 + pass * .16);
490
+ const angle = random() * Math.PI * 2;
491
+ const trail = Math.pow(random(), 2.1);
492
+ const x = shadowCx + Math.cos(angle) * radius * 1.06 * ring + dx * trail * .48;
493
+ const y = shadowCy + Math.sin(angle) * radius * tilt * .76 * ring + dy * trail * .32;
494
+ const size = .35 + random() * (1.2 + pass * .28);
495
+ g.rect(x, y, size, size * (.55 + random() * .8));
496
+ }
497
+ g.fillStyle = dustTones[pass];
498
+ g.fill();
499
+ }
500
+ g.restore();
501
+
502
+ // Two pencil directions interlock to form the dense hand-shaded core.
503
+ const strokes = Math.round(1040 * state.density);
504
+
505
+ g.save();
506
+ g.lineCap = "round";
507
+ g.lineWidth = .72;
508
+ for (let pass = 0; pass < 2; pass++) {
509
+ g.beginPath();
510
+ const cross = pass === 0 ? -.38 : .46;
511
+ for (let i = pass; i < strokes; i += 2) {
512
+ const ring = Math.pow(random(), .62);
513
+ const angle = random() * Math.PI * 2;
514
+ const trail = Math.pow(random(), 2.05);
515
+ const x = shadowCx + Math.cos(angle) * radius * 1.04 * ring + dx * trail * .5;
516
+ const y = shadowCy + Math.sin(angle) * radius * tilt * .75 * ring + dy * trail * .34;
517
+ const length = 3 + random() * (9 + (1 - ring) * 14);
518
+ const strokeAngle = radians + cross + (random() - .5) * .15;
519
+ g.moveTo(x, y);
520
+ g.lineTo(x + Math.cos(strokeAngle) * length, y + Math.sin(strokeAngle) * length * .62);
521
+ }
522
+ g.strokeStyle = pass === 0 ? "rgba(32,34,29,.14)" : "rgba(32,34,29,.11)";
523
+ g.stroke();
524
+ }
525
+ g.restore();
526
+
527
+ // Paper tooth interrupts the graphite and leaves tiny irregular highlights.
528
+ g.save();
529
+ g.beginPath();
530
+ const tooth = Math.round(290 * state.density);
531
+ for (let i = 0; i < tooth; i++) {
532
+ const ring = Math.pow(random(), .72);
533
+ const angle = random() * Math.PI * 2;
534
+ const x = shadowCx + Math.cos(angle) * radius * 1.02 * ring + dx * Math.pow(random(), 2.4) * .34;
535
+ const y = shadowCy + Math.sin(angle) * radius * tilt * .7 * ring + dy * Math.pow(random(), 2.4) * .24;
536
+ const size = .45 + random() * 1.2;
537
+ g.rect(x, y, size, size * .55);
538
+ }
539
+ g.fillStyle = "rgba(242,238,228,.16)";
540
+ g.fill();
541
+ g.restore();
542
+
543
+ // The contact crevice: short tangent strokes hug the exact lower ellipse.
544
+ g.save();
545
+ g.lineCap = "round";
546
+ g.lineWidth = .9;
547
+ const contact = Math.round(300 * state.density);
548
+ for (let pass = 0; pass < 3; pass++) {
549
+ g.beginPath();
550
+ for (let i = pass; i < contact; i += 3) {
551
+ const angle = random() * Math.PI;
552
+ const darkness = .5 + Math.cos(angle - radians) * .5;
553
+ const depth = Math.pow(random(), 2.6) * (5 + darkness * 8) + pass * 1.2;
554
+ const point = ellipsePoint(cx, cy, radius * (.97 + random() * .045), tilt, angle, state.thickness + 1 + depth);
555
+ const tangentX = -Math.sin(angle);
556
+ const tangentY = Math.cos(angle) * tilt;
557
+ const norm = Math.hypot(tangentX, tangentY) || 1;
558
+ const length = 3 + random() * (8 + darkness * 11);
559
+ g.moveTo(point.x - tangentX / norm * length * .5, point.y - tangentY / norm * length * .5);
560
+ g.lineTo(point.x + tangentX / norm * length * .5, point.y + tangentY / norm * length * .5);
561
+ }
562
+ g.strokeStyle = ["rgba(27,29,25,.36)", "rgba(27,29,25,.25)", "rgba(27,29,25,.16)"][pass];
563
+ g.stroke();
564
+ }
565
+ g.restore();
566
+ }
567
+
568
+ function baseSidePath(g, cx, cy, radius, tilt) {
569
+ const steps = 120;
570
+ g.beginPath();
571
+ for (let i = 0; i <= steps; i++) {
572
+ const point = ellipsePoint(cx, cy, radius, tilt, Math.PI * i / steps);
573
+ if (i === 0) g.moveTo(point.x, point.y);
574
+ else g.lineTo(point.x, point.y);
575
+ }
576
+ for (let i = steps; i >= 0; i--) {
577
+ const point = ellipsePoint(cx, cy, radius, tilt, Math.PI * i / steps, state.thickness);
578
+ g.lineTo(point.x, point.y);
579
+ }
580
+ g.closePath();
581
+ }
582
+
583
+ function drawBaseSide(g, cx, cy, radius, tilt, random) {
584
+ baseSidePath(g, cx, cy, radius, tilt);
585
+ const shadowDirection = state.shadowAngle * Math.PI / 180;
586
+ const gradientX = Math.cos(shadowDirection) * radius;
587
+ const gradientY = Math.sin(shadowDirection) * radius * tilt;
588
+ const sideTone = g.createLinearGradient(cx - gradientX, cy - gradientY, cx + gradientX, cy + gradientY);
589
+ sideTone.addColorStop(0, "#e6e1d6");
590
+ sideTone.addColorStop(.47, "#d8d3c7");
591
+ sideTone.addColorStop(1, "#aaa79f");
592
+ g.fillStyle = sideTone;
593
+ g.fill();
594
+ g.strokeStyle = "rgba(39,41,35,.7)";
595
+ g.lineWidth = 1.5;
596
+ g.stroke();
597
+
598
+ // Vertical pencil grain follows the cylindrical wall and darkens to the right.
599
+ const hatchCount = Math.round(138 * state.density);
600
+ g.save();
601
+ g.lineCap = "round";
602
+ g.lineWidth = .66;
603
+ for (let pass = 0; pass < 3; pass++) {
604
+ g.beginPath();
605
+ for (let i = 1; i < hatchCount; i++) {
606
+ const angle = Math.PI * i / hatchCount;
607
+ const darkness = .5 + Math.cos(angle - shadowDirection) * .5;
608
+ const bucket = Math.min(2, Math.floor(darkness * 3));
609
+ if (bucket !== pass) continue;
610
+ const top = ellipsePoint(cx, cy, radius, tilt, angle);
611
+ const jitter = (random() - .5) * 4;
612
+ g.moveTo(top.x + jitter * .25, top.y + 2 + random() * 3);
613
+ g.lineTo(top.x + jitter, top.y + state.thickness - 2 - random() * 5);
614
+ }
615
+ g.strokeStyle = ["rgba(37,39,34,.13)", "rgba(37,39,34,.21)", "rgba(37,39,34,.31)"][pass];
616
+ g.stroke();
617
+ }
618
+ g.restore();
619
+
620
+ // Fine diagonal cross-hatching sits inside the wall rather than on the ground.
621
+ g.save();
622
+ baseSidePath(g, cx, cy, radius, tilt);
623
+ g.clip();
624
+ g.lineWidth = .48;
625
+ g.lineCap = "round";
626
+ for (let pass = 0; pass < 2; pass++) {
627
+ const slope = pass === 0 ? .24 : -.18;
628
+ g.beginPath();
629
+ for (let x = cx - radius - 90; x < cx + radius + 90; x += 8 / state.density) {
630
+ g.moveTo(x, cy - 8);
631
+ g.lineTo(x + slope * 330, cy + state.thickness + 170);
632
+ }
633
+ g.strokeStyle = pass === 0 ? "rgba(39,41,35,.075)" : "rgba(39,41,35,.05)";
634
+ g.stroke();
635
+ }
636
+ g.restore();
637
+
638
+ // Reinforce both seams with several imperfect graphite passes.
639
+ pencilStroke(g, () => {
640
+ g.beginPath();
641
+ g.ellipse(cx, cy, radius, radius * tilt, 0, 0, Math.PI, false);
642
+ }, "#272923", 1.05, 3);
643
+ pencilStroke(g, () => {
644
+ g.beginPath();
645
+ g.ellipse(cx, cy + state.thickness, radius, radius * tilt, 0, 0, Math.PI, false);
646
+ }, "#22241f", 1.45, 4);
647
+ }
648
+
649
+ function hatchClippedShape(g, clipFn, bounds, angle, spacing, color, alpha, random) {
650
+ g.save();
651
+ clipFn();
652
+ g.clip();
653
+ g.translate(bounds.cx, bounds.cy);
654
+ g.rotate(angle);
655
+ g.translate(-bounds.cx, -bounds.cy);
656
+ g.strokeStyle = color;
657
+ g.globalAlpha = alpha;
658
+ g.lineWidth = .58;
659
+ g.lineCap = "round";
660
+ g.beginPath();
661
+ for (let x = bounds.cx - bounds.radius * 1.7; x < bounds.cx + bounds.radius * 1.7; x += spacing) {
662
+ const offset = (random() - .5) * 2;
663
+ g.moveTo(x + offset, bounds.cy - bounds.radius);
664
+ g.lineTo(x + offset + (random() - .5) * 2, bounds.cy + bounds.radius);
665
+ }
666
+ g.stroke();
667
+ g.restore();
668
+ }
669
+
670
+ function drawTop(g, cx, cy, radius, tilt, random) {
671
+ ellipsePath(g, cx, cy, radius, tilt);
672
+ g.fillStyle = "#f1ede2";
673
+ g.fill();
674
+ hatchClippedShape(
675
+ g,
676
+ () => ellipsePath(g, cx, cy, radius, tilt),
677
+ { cx, cy, radius },
678
+ -Math.PI / 4,
679
+ 8 / state.density,
680
+ "#4c4d47",
681
+ .15,
682
+ random
683
+ );
684
+ pencilStroke(g, () => ellipsePath(g, cx, cy, radius, tilt), "#22241f", 1.35, 3);
685
+ }
686
+
687
+ function drawLiftWalls(g, cx, cy, radius, tilt, start, sweep) {
688
+ if (state.lift < 1) return;
689
+ const end = start + sweep;
690
+ const centerTop = { x: cx, y: cy - state.lift };
691
+ const centerBottom = { x: cx, y: cy };
692
+ [start, end].forEach((angle, index) => {
693
+ const edgeTop = ellipsePoint(cx, cy, radius, tilt, angle, -state.lift);
694
+ const edgeBottom = ellipsePoint(cx, cy, radius, tilt, angle, 0);
695
+ g.beginPath();
696
+ g.moveTo(centerTop.x, centerTop.y);
697
+ g.lineTo(edgeTop.x, edgeTop.y);
698
+ g.lineTo(edgeBottom.x, edgeBottom.y);
699
+ g.lineTo(centerBottom.x, centerBottom.y);
700
+ g.closePath();
701
+ g.fillStyle = index === 0 ? "#526586" : "#60759b";
702
+ g.fill();
703
+ g.strokeStyle = "rgba(34,36,31,.82)";
704
+ g.lineWidth = 1.2;
705
+ g.stroke();
706
+
707
+ g.save();
708
+ g.clip();
709
+ g.strokeStyle = "rgba(30,32,28,.28)";
710
+ g.lineWidth = .65;
711
+ g.setLineDash([1.5, 2.5]);
712
+ g.beginPath();
713
+ for (let y = cy - state.lift + 4; y < cy + 4; y += 5) {
714
+ g.moveTo(cx - radius, y);
715
+ g.lineTo(cx + radius, y + 4);
716
+ }
717
+ g.stroke();
718
+ g.restore();
719
+ });
720
+
721
+ // Close the lifted piece along its curved outer rim.
722
+ const steps = Math.max(18, Math.ceil(Math.abs(sweep) / (Math.PI / 90)));
723
+ g.beginPath();
724
+ for (let i = 0; i <= steps; i++) {
725
+ const angle = start + sweep * (i / steps);
726
+ const point = ellipsePoint(cx, cy, radius, tilt, angle, -state.lift);
727
+ if (i === 0) g.moveTo(point.x, point.y);
728
+ else g.lineTo(point.x, point.y);
729
+ }
730
+ for (let i = steps; i >= 0; i--) {
731
+ const angle = start + sweep * (i / steps);
732
+ const point = ellipsePoint(cx, cy, radius, tilt, angle, 0);
733
+ g.lineTo(point.x, point.y);
734
+ }
735
+ g.closePath();
736
+ g.fillStyle = "#526586";
737
+ g.fill();
738
+ g.strokeStyle = "rgba(34,36,31,.8)";
739
+ g.lineWidth = 1.05;
740
+ g.stroke();
741
+ }
742
+
743
+ function drawWedge(g, cx, cy, radius, tilt, start, sweep, random) {
744
+ drawLiftWalls(g, cx, cy, radius, tilt, start, sweep);
745
+ wedgePath(g, cx, cy, radius, tilt, start, sweep, -state.lift);
746
+ g.fillStyle = "#778db8";
747
+ g.fill();
748
+
749
+ hatchClippedShape(
750
+ g,
751
+ () => wedgePath(g, cx, cy, radius, tilt, start, sweep, -state.lift),
752
+ { cx, cy: cy - state.lift, radius },
753
+ Math.PI / 5,
754
+ 5.5 / state.density,
755
+ "#26354d",
756
+ .24,
757
+ random
758
+ );
759
+
760
+ pencilStroke(
761
+ g,
762
+ () => wedgePath(g, cx, cy, radius, tilt, start, sweep, -state.lift),
763
+ "#20231f",
764
+ 1.25,
765
+ 3
766
+ );
767
+
768
+ // A final continuous boundary guarantees a visually closed colored portion.
769
+ g.save();
770
+ wedgePath(g, cx, cy, radius, tilt, start, sweep, -state.lift);
771
+ g.strokeStyle = "rgba(30,32,28,.92)";
772
+ g.lineWidth = 1.45;
773
+ g.setLineDash([]);
774
+ g.stroke();
775
+ g.restore();
776
+ }
777
+
778
+ function drawLabel(g, cx, cy, radius, tilt) {
779
+ const percentage = Math.round(state.sweep / 3.6);
780
+ const labelY = cy + radius * tilt + state.thickness + 98;
781
+ g.save();
782
+ g.textAlign = "center";
783
+ g.fillStyle = "#292b25";
784
+ g.font = "600 36px ui-monospace, SFMono-Regular, Menlo, monospace";
785
+ g.fillText(`${percentage}%`, cx, labelY);
786
+ g.fillStyle = "#65675f";
787
+ g.font = "15px ui-monospace, SFMono-Regular, Menlo, monospace";
788
+ g.letterSpacing = "5px";
789
+ g.fillText("OF NOISE", cx, labelY + 34);
790
+ g.restore();
791
+ }
792
+
793
+ function draw() {
794
+ const started = performance.now();
795
+ const narrow = window.innerWidth <= 520;
796
+ const cx = canvas.width * .5;
797
+ const cy = narrow ? 345 : 320;
798
+ const radius = narrow ? 280 : 270;
799
+ const start = state.start * Math.PI / 180;
800
+ const sweep = state.sweep * Math.PI / 180;
801
+ const nextBaseKey = [
802
+ narrow ? 1 : 0,
803
+ state.thickness,
804
+ state.tilt,
805
+ state.shadowAngle,
806
+ state.shadowLength,
807
+ state.density
808
+ ].join("|");
809
+ const cacheHit = baseFrameReady && baseFrameKey === nextBaseKey;
810
+
811
+ if (!cacheHit) {
812
+ const baseSeed = 2117
813
+ + Math.round(state.thickness * 3)
814
+ + Math.round(state.tilt * 1000)
815
+ + Math.round(state.shadowAngle * 5)
816
+ + Math.round(state.shadowLength * 2)
817
+ + Math.round(state.density * 100);
818
+ const baseRandom = mulberry32(baseSeed);
819
+ baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
820
+ drawPaper(baseCtx, baseRandom);
821
+ drawGraphiteShadow(baseCtx, cx, cy, radius, state.tilt, baseRandom);
822
+ drawBaseSide(baseCtx, cx, cy, radius, state.tilt, baseRandom);
823
+ drawTop(baseCtx, cx, cy, radius, state.tilt, baseRandom);
824
+ baseFrameReady = true;
825
+ baseFrameKey = nextBaseKey;
826
+ }
827
+
828
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
829
+ const wedgeRandom = mulberry32(4093 + Math.round(state.start * 11) + Math.round(state.sweep * 17) + Math.round(state.lift * 7));
830
+ drawWedge(ctx, cx, cy, radius, state.tilt, start, sweep, wedgeRandom);
831
+ pencilStroke(ctx, () => ellipsePath(ctx, cx, cy, radius, state.tilt), "#22241f", 1.15, 2);
832
+ drawLabel(ctx, cx, cy, radius, state.tilt);
833
+
834
+ const elapsed = performance.now() - started;
835
+ renderStats.textContent = `${cacheHit ? "Cached" : "Rebuilt"} · ${elapsed.toFixed(1)} ms`;
836
+ }
837
+
838
+ function scheduleDraw() {
839
+ if (framePending) return;
840
+ framePending = true;
841
+ requestAnimationFrame(() => {
842
+ framePending = false;
843
+ draw();
844
+ });
845
+ }
846
+
847
+ document.querySelectorAll(".preset").forEach(button => {
848
+ button.addEventListener("click", () => {
849
+ const percent = Number(button.dataset.percent);
850
+ state.sweep = percent * 3.6;
851
+ syncInputs();
852
+ setPresetState(percent);
853
+ scheduleDraw();
854
+ });
855
+ });
856
+
857
+ document.getElementById("resetButton").addEventListener("click", () => {
858
+ Object.assign(state, defaults);
859
+ syncInputs();
860
+ setPresetState(28);
861
+ scheduleDraw();
862
+ });
863
+
864
+ document.getElementById("downloadButton").addEventListener("click", () => {
865
+ const percent = Math.round(state.sweep / 3.6);
866
+ const exportCanvas = document.createElement("canvas");
867
+ exportCanvas.width = canvas.width;
868
+ exportCanvas.height = canvas.height;
869
+ const exportCtx = exportCanvas.getContext("2d");
870
+ exportCtx.drawImage(baseCanvas, 0, 0);
871
+ exportCtx.drawImage(canvas, 0, 0);
872
+ const link = document.createElement("a");
873
+ link.download = `pencil-portion-${percent}.png`;
874
+ link.href = exportCanvas.toDataURL("image/png");
875
+ link.click();
876
+ });
877
+
878
+ window.addEventListener("resize", scheduleDraw, { passive: true });
879
+ createFields();
880
+ draw();
881
+ </script>
882
+ </body>
883
+ </html>