类型:Site模板 · 收录:2026-10-08 21:01:33 · 源码:会员可见
ROLE
You are an expert graphics programmer. Write ONE complete file, index.html, with inline CSS and JS. No libraries, no build step, no images, no video, no placeholders, no "TODO", no omitted sections. Output the whole file in a single code block. It must work when opened by double-click (file://) in current Chrome, Edge, Firefox and Safari.
WHAT YOU ARE BUILDING
"Jelly Garden": a 3D playground of glossy gummy fruit that the user can grab, stretch, wobble, shake, resize and chop with a cleaver. Raw WebGL2 + GLSL ES 3.00, CPU soft-body physics.
HARD RULES (breaking any of these causes a blank or broken page)
1. Every shader source starts with exactly "#version 300 esnprecision highp float;n" — nothing before #version, not even a newline.
2. Use `in`/`out` and `texture()`, never attribute/varying/gl_FragColor/texture2D.
3. Fixed attribute locations in every mesh shader: layout(location=0) aPos, 1 aNor, 2 aRest, 3 aPar (vec4).
4. Context: canvas.getContext('webgl2', {alpha:true, premultipliedAlpha:true, antialias:true}). Clear to (0,0,0,0). Jellies write alpha 1. Shadows are drawn first with gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) and premultiplied colour.
5. Index buffers are Uint32Array drawn with gl.UNSIGNED_INT.
6. Check and console.error every shader compile and program link log.
7. Physics uses a FIXED substep of 1/240 s, at most 12 substeps per frame, frame dt clamped to 0.05.
8. All pointer handling uses Pointer Events on the canvas; wrap setPointerCapture in try/catch.
9. Compute the camera matrices once before attaching any event listener, so ray-casting never sees a null matrix.
10. Never compile shaders, create buffers, or allocate large arrays inside the frame loop (except when a jelly is created, removed or cut).
BUILD IN THIS ORDER — finish and mentally verify each stage before the next
Stage 1: page chrome (HTML/CSS) + WebGL setup + camera + one static kiwi mesh rendering with lighting.
Stage 2: particle cage + skinning + physics; the kiwi drops and rests on the floor.
Stage 3: dragging, orbit, zoom; the kiwi stretches and wobbles.
Stage 4: remaining four jelly types, collisions between jellies, opening scene.
Stage 5: floor shadows.
Stage 6: toolbar actions, add pills, tags.
Stage 7: knife model, aim/chop animation, mesh cutting.
IF YOU RUN SHORT OF SPACE: simplify surface patterns and shadows first; never drop physics, dragging, the toolbar, or the knife.
━━━━━━━━ 1. PAGE CHROME ━━━━━━━━
Font: Google "Outfit" 300/400/500/800 via <link> (with preconnects). Stack: 'Outfit','Avenir Next','Century Gothic','Segoe UI',system-ui,sans-serif.
:root { --ink:#121214; --text:#5d5e66; --muted:#8b8c94; --line:rgba(18,18,20,.07); }
* { box-sizing:border-box; margin:0; padding:0 } html,body { height:100%; overflow:hidden }
body { color:var(--text); touch-action:none; user-select:none; -webkit-font-smoothing:antialiased;
background: radial-gradient(110% 85% at 56% 38%, #fbfbfc 0%, #f1f1f4 55%, #e6e7ec 100%); }
All of these are position:fixed:
• h1.title (z-index 1, pointer-events none): two block spans "JELLY" / "GARDEN". left clamp(16px,2.5vw,34px); top clamp(64px,10vh,84px); weight 800; color var(--ink); font-size clamp(54px,8.3vw,128px); line-height .87; letter-spacing -.022em. It is BEHIND the canvas so jellies pass in front of the text.
• canvas#gl (z-index 2): inset 0; width/height 100%.
• .tag labels (z-index 3) — see Tags.
• UI (z-index 4):
– Brand top-left (left clamp(16px,2.5vw,34px); top 22px): inline SVG 24×22 with two #121214 circles (cx7 cy7 r5.2; cx17.5 cy15.5 r4.2) + text "soft / lab", 15px, letter-spacing .06em, color var(--ink), gap 16px.
– Add pills top-centre (top 19px; left 50%; transform translateX(-50%); width max-content; max-width calc(100vw - 24px)). Five buttons, each a 7px round dot + label: Fruit #5f9a1d, Bloom #e0668f, Citrus #f0bd1c, Melon #a9d46a, Berry #e0231f.
– Dock bottom-centre (bottom 46px; left 50%; translateX(-50%); width max-content; max-width calc(100vw - 24px); text-align center): hint div "PLAY: Drag a jelly to wobble it" (12.5px; letter-spacing .07em; color var(--muted); margin-bottom 13px), then pills in this exact order: Hard, Soft, Knife, Scale, Shake, Reset, Tags, View. "Soft" starts active. Dock pill padding 7px 17px.
– Note bottom-right (right clamp(16px,2.5vw,34px); bottom 50px; width 172px; 12px; line-height 1.5; letter-spacing .05em; color var(--muted)): "A soft study of shape, wobble and light. Pick one up, let it go, and watch it settle."
• svg#cut (z-index 5; inset 0; 100%×100%; pointer-events none; opacity 0) with two <line>: halo (stroke #fff; width 5; round caps; opacity .7) and blade (stroke var(--ink); width 1.5; round caps). Class "live" → opacity 1. Class "done" → opacity 0 with transition opacity .4s.
.pills { display:flex; gap:10px; flex-wrap:wrap; justify-content:center }
.pill { font:inherit; font-size:12.5px; letter-spacing:.03em; color:#4b4c53; background:#fff; border:1px solid var(--line); border-radius:999px; padding:7px 15px; cursor:pointer; display:inline-flex; align-items:center; gap:8px;
box-shadow:0 1px 1px rgba(20,20,30,.04),0 4px 14px rgba(20,20,30,.05);
transition:transform .18s cubic-bezier(.3,1.6,.5,1),background .15s,color .15s,box-shadow .15s }
.pill:hover { transform:translateY(-1px); box-shadow:0 1px 1px rgba(20,20,30,.05),0 7px 18px rgba(20,20,30,.08) }
.pill:active { transform:scale(.94) }
.pill:focus-visible { outline:2px solid var(--ink); outline-offset:2px }
.pill.on { background:var(--ink); color:#fff; border-color:var(--ink) }
Responsive:
@media (max-width:1040px) { hide the note }
@media (max-width:760px) { brand top 16px; add pills top 52px, width calc(100vw - 20px); .pills gap 5px; .pill padding 6px 10px, 11.5px, gap 6px; dock pills padding 6px 13px; title top 104px, font-size clamp(46px,15vw,84px); dock bottom 18px; hint 11.5px, margin-bottom 10px }
@media (max-height:520px) { title font-size clamp(40px,13vh,84px), top 56px; dock bottom 14px; hint margin-bottom 8px; note bottom 18px }
No WebGL2 → show centred text: "This playground needs WebGL 2. Please open it in a current version of Chrome, Edge, Firefox or Safari."
━━━━━━━━ 2. CAMERA ━━━━━━━━
World is y-up, floor at y=0, +z toward the viewer. Perspective FOV 28°, near 0.5, far 200. Column-major 4×4 matrices; write your own perspective, lookAt, multiply, invert.
portrait = (width/height < 0.9). Target = (0, 0.3, portrait ? -0.9 : 0.15).
baseDist = max(halfW / (tan(FOV/2)·aspect), halfH / tan(FOV/2)), with halfW,halfH = 7.2, 4.3 (landscape) or 3.5, 5.6 (portrait).
eye = target + baseDist·zoom·(cos(pitch)·sin(yaw), sin(pitch), cos(pitch)·cos(yaw)). Default pitch 1.12, yaw 0, zoom 1. Each frame ease yaw/pitch by 0.14 and zoom by 0.16 toward their targets.
Canvas backing size = CSS size × min(devicePixelRatio, 2). On resize recompute; if portrait flips, reset the scene.
Ray from a screen point: unproject the far-plane point with the inverse view-projection, subtract the eye, normalise.
━━━━━━━━ 3. MESHES ━━━━━━━━
Every mesh is {pos: Float32Array xyz, par: Float32Array 4 per vertex, idx: Uint32Array}, built once per type in LOCAL rest space with its lowest point at y=0. After building each closed component, compute its signed volume about its centroid and flip triangle winding if negative, so normals face outward.
SLAB generator — slab({R(θ), H, bt, bb, dome=0.06, sx=1, sz=1, oz=0}): 96 angular segments, vertices welded (wrap the last column to the first; single top-centre and bottom-centre vertices). With B = max(bt,bb), rings from top centre to bottom centre:
12 top rings: k=i/12, radial = (R−B)·k + (B−bt)·k, y = H + dome·(1−k²), profile = k
7 top-bevel rings: a=i/7·π/2, radial = (R−B)+(B−bt)+bt·sin a, y = H−bt+bt·cos a, profile = 1 + i/21
3 side rings if H−bt−bb > 0.02: radial = R, y from H−bt down to bb, profile = 1.333 + i/9
7 bottom-bevel rings: radial = (R−B)+(B−bb)+bb·cos a, y = bb−bb·sin a, profile = 1.667 + i/21
9 bottom rings: k=1−i/10, radial = (R−B)·k+(B−bb)·k, y=0, profile = 2 + i/10
position = (cosθ·radial·sx, y, sinθ·radial·sz + oz); par = (radial/R(θ), profile, 0, 0)
Types:
• fruit / "Kiwi": R=1.55, H 0.62, bt 0.15, bb 0.2. Shadow tint (0.42,0.66,0.10).
• berry / "Strawberry": H 0.62, bt 0.1, bb 0.36, sx 1.2, sz 0.98; with t=|θ| folded to [0,π]: R = (0.80 + 0.52·exp(−1.7·√(t²+0.015)) + 0.10·exp(−((t−2.1)/0.7)²)) · (1 − 0.10·exp(−((π−t)/0.3)²)). Tip points along +x. Tint (0.92,0.14,0.10).
• citrus / "Lemon": R=1.35, H 0.44, bt 0.11, bb 0.14. Tint (1.00,0.80,0.12).
• melon / "Melon": half-disc. Rc=1.9, C=0.42·Rc, oz=C. R(θ): with d=(cosθ,sinθ), cd=C·d.z, t = −cd + √(cd² − (C²−Rc²)); if d.z < 0, t = min(t, −C/d.z). H 0.66, bt 0.13, bb 0.16. (The circle's centre lands on the local origin.) Tint (0.95,0.22,0.22).
• bloom / "Tulip": union of closed tubes swept along x, each 24 rings × 20 segments with pole vertices. Lobe(α, off, splay, L, Rm, fr, ft): s = max(sin(π·t^0.7),0); r = Rm·s^0.9; o = off·s + splay·t²; with d=(0,cosα,sinα) and tangent g=(0,−sinα,cosα): point = (t·L, 0, 0) + d·(o + cosφ·fr·r) + g·(sinφ·ft·r).
Lobes: (0, 0, 0, 2.3, 0.56, 0.85, 0.85); (π/2, 0.30, 0.16, 2.55, 0.62, 0.62, 1.0); (7π/6, 0.30, 0.14, 2.5, 0.62, 0.62, 1.0); (11π/6, 0.30, 0.14, 2.45, 0.62, 0.62, 1.0).
Stem: 8×10 tube, r = 0.10·sin(π·clamp(t,0.03,0.97))^0.3, centre (0.2−0.62t, −0.12t², 0). par = (t, isStem?1:0, lobeId, 0).
Then scale all y by 0.88 and shift so min y = 0. Tint (1.00,0.46,0.18).
━━━━━━━━ 4. PARTICLE CAGE ━━━━━━━━
buildLattice(pos, idx, h=0.3):
1. Bounding box; per axis n = max(1, round(extent/h)), cell size = extent/n (cage planes coincide with the box faces).
2. Mark cells containing surface samples: for each triangle sample barycentric points with subdivision s = clamp(ceil(longestEdge / (0.45·minCell)), 1, 24).
3. On a grid padded by one cell each side, flood-fill (6-connected) from a corner through unmarked cells = outside. Every other cell is jelly.
4. Create one particle per corner of each jelly cell (deduplicated). Store rest positions q relative to their centroid, plus the centroid c0 and cmY = c0.y − box.min.y.
5. Edges: for each particle, connect to neighbours at these 13 offsets if they exist: (1,0,0)(0,1,0)(0,0,1)(1,1,0)(1,−1,0)(1,0,1)(1,0,−1)(0,1,1)(0,1,−1)(1,1,1)(1,1,−1)(1,−1,1)(1,−1,−1). Store rest length.
6. Skinning: per mesh vertex, the 8 corner particle indices of its cell (Uint16Array) and trilinear weights (Float32Array).
7. Collision radius rad = 0.33 × smallest cell size.
Body state: p, prev, v (Float32Array n×3), rot quaternion [x,y,z,w], scale, pop (1 alive → 0 dying), cm, particle AABB, skinned vertex positions vp and normals vn, mesh AABB, its own VAO with DYNAMIC pos/normal buffers and the type's STATIC rest/par/index buffers, a random seed, and a DOM tag.
Spawn at (x, z, yaw, dropY): p = (x,0,z) + Ry(yaw)·(q·scale) with y += dropY + cmY·scale; rot = (0, sin(yaw/2), 0, cos(yaw/2)); scale starts 0.55 if dropY>0 else 1; prev = p.
━━━━━━━━ 5. PHYSICS — copy this structure ━━━━━━━━
const GRAV = 22, SUB = 1/240;
MODES = { soft:{beta:0.030, edge:0.30, damp:1.7}, hard:{beta:0.22, edge:0.85, damp:7.0} };
bounds = landscape {x:7.1, z:4.4} | portrait {x:3.5, z:5.8}
function substep(h) {
// 1 integrate
for each body: for each particle: prev = p; v.y -= GRAV*h; p += v*h;
// 2 grab
if (grab) for each grabbed particle k: p += (grab.target + offset[k] - p) * weight[k] * 0.3;
// 3 per body: distance constraints, then shape matching
for each body {
for each edge (i,j,L): d = p[j]-p[i]; len = |d|; if (len < 1e-8) continue;
c = (len - L*scale) / len * (mode.edge * 0.5); p[i] += d*c; p[j] -= d*c;
shapeMatch(body, mode.beta);
recompute particle AABB;
}
// 4 jelly–jelly collisions
for each pair (a,b) whose AABBs overlap when expanded by r = a.rad*a.scale + b.rad*b.scale:
collect each body's particles inside the overlap box, then for every pair closer than r:
push them apart equally along the line between them by (r - dist)/2 each.
// 5 floor, walls, velocity, damping
for each body {
for each particle {
if (p.y < 0) { p.y = 0; p.x = prev.x + (p.x-prev.x)*0.62; p.z = prev.z + (p.z-prev.z)*0.62; }
clamp p.x to ±bounds.x and p.z to ±bounds.z;
v = (p - prev) / h;
}
vMean = average v; kd = exp(-mode.damp * h);
for each particle: v = vMean + (v - vMean) * kd;
}
}
function shapeMatch(b, beta) {
cm = mean of p;
// A = Σ (p - cm) ⊗ q (3×3, A[row][col] = Σ (p-cm)[row] * q[col])
// rotation extraction, warm-started from b.rot, 4 iterations:
repeat 4 times {
R = matrix from quaternion b.rot;
omega = ( R.col0 × A.col0 + R.col1 × A.col1 + R.col2 × A.col2 )
/ ( | R.col0·A.col0 + R.col1·A.col1 + R.col2·A.col2 | + 1e-9 );
w = |omega|; if (w < 1e-9) break;
b.rot = normalize( quatFromAxisAngle(omega / w, w) * b.rot );
}
R = matrix from b.rot;
for each particle: p += ( cm + R * (q * b.scale) - p ) * beta;
b.cm = cm;
}
Each frame: accumulate dt, run substeps while accumulator ≥ SUB (max 12, then zero the accumulator). Ease every body's scale: scale += (globalScale·pop − scale)·0.12. A dying body sets pop → 0.02 at rate 0.22 and is destroyed when pop < 0.12 (delete its buffers, VAO and tag).
Skinning each frame, per body: vertex = Σ weight·particle over its 8 corners; track the mesh AABB; zero normals, add each triangle's un-normalised face normal to its three vertices; upload both with bufferSubData. The shader normalises.
━━━━━━━━ 6. SHADERS ━━━━━━━━
Vertex: pass world aPos, aNor, aRest, aPar; gl_Position = uVP * vec4(aPos,1).
Fragment uniforms: uCam, int uType (0 kiwi, 1 berry, 2 tulip, 3 lemon, 4 melon, 5 knife), float uSeed.
Helpers: h11(n)=fract(sin(n*127.1+uSeed)*43758.5453); h21(vec2); value noise vn(vec2); edgeMask(pv)=smoothstep(1.06,1.30,pv)*(1.-smoothstep(1.72,1.96,pv)).
With th=atan(vRest.z,vRest.x), a=th/6.28318+.5, rho=vPar.x, pv=vPar.y (angular cells must wrap: use mod(floor(a*n), n) as the cell id):
• kiwi: flesh (.50,.72,.10) with 52 pale streaks (.80,.92,.38) fading by rho; darker (.36,.60,.05) from rho .62→.96; cream core (.99,.98,.86) of radius ≈.23 with a wavy edge; 26 angular cells each holding one near-black elliptical seed at rho = .40+.11·hash, long axis radial; rim tint; speckled dark green skin (.17,.42,.03) where edgeMask(pv) is 1.
• berry: cut face = mix((1,.80,.76),(.93,.16,.12), smoothstep(.30,.92,rho)) with 64 white fibres, a white elongated core around the x axis, deep red beyond rho .88. Skin (where pv > 1.0…1.14) = red (.84,.05,.04) with a staggered 30 × 6.5 grid of yellow seeds (.98,.72,.22) in darker dimples.
• tulip: stem (par.y>.5) green; petals mix((1,.60,.38),(1,.34,.10), smoothstep(0,.75,t)), slight per-lobe variation, redder tips.
• lemon: 10 segments, white membranes and centre, pith ring from rho .80, yellow pulp with noise, peel (1,.80,.05) beyond rho .91 and on the edge.
• melon: d = length(vRest.xz)/1.9. Flesh (1,.36,.38)→(.93,.10,.16); 18 angular cells of dark teardrop seeds at d = .50+.16·hash; pale band (.96,.95,.70) from d .76–.84; rind beyond .86 striped between (.10,.38,.10) and (.42,.70,.22).
• On cut faces (vPar.w = 1) multiply the kiwi and melon seed masks by a dot pattern in vRest.y so seeds read as dots, not streaks.
Lighting (convert albedo with pow 2.2 first):
N = normalize(vNor); V = normalize(uCam - vPos); if (dot(N,V) < 0.) N = -N;
L = normalize(vec3(.35,1.,-.30)); wrap = clamp(dot(N,L)*.5+.5,0.,1.); ndv = clamp(dot(N,V),0.,1.); fr = pow(1.-ndv,4.);
col = alb*(.50+.72*wrap*wrap);
sat = mix(alb*1.9, pow(alb,vec3(.72)), .35);
col += sat*(.30*pow(1.-ndv,2.) + .16*clamp(.5-dot(N,L)*.5,0.,1.)) + sat*.10*clamp(-N.y,0.,1.);
R = reflect(-V,N); env = mix(vec3(.50,.51,.56), vec3(1.02), smoothstep(-.3,1.,R.y));
four rectangular softbox highlights b1..b4 evaluated on R (a box test in a plane perpendicular to a light direction, soft edges);
F = .05+.60*fr; col = col*(1.-F*.55) + env*F; col += (b1*1.25+b2*.9+b3*.6+b4*.5)*(.38+.62*fr);
col *= mix(.70,1.,smoothstep(-.05,.30, vPos.y+N.y*.25+.12)); // contact darkening
col = col/(1.+.12*col); out = vec4(pow(col, vec3(1./2.2)), 1.);
━━━━━━━━ 7. FLOOR SHADOWS ━━━━━━━━
Three 1024² RGBA8 render targets (the last with mipmaps, LINEAR_MIPMAP_LINEAR).
Pass A: clear to 0; no depth, no blend; draw all meshes with gl_Position = vec4((aPos.xz − aPos.y*vec2(0.35,−0.30)) / 13.0, 0, 1); output vec4(tint*a, a) with a = clamp(1 − y*0.16, 0.25, 1).
Pass B, C: separable 5-tap Gaussian (weights .2270, .3162 at ±1.3846, .0703 at ±3.2308 texels × 1.6), horizontal then vertical; generateMipmap on the result.
Main pass: draw a floor quad spanning ±13 at y=0 FIRST with blending on and depth off: sample textureLod at 0, 2.6 and 4.4; alpha = 0.20 × each; colour = mix(vec3(.10,.10,.15), tint*.85, .50*smoothstep(0.,.6,mid.a)); fade near the quad's border; output premultiplied. Then disable blend, enable depth, draw jellies.
━━━━━━━━ 8. INTERACTION ━━━━━━━━
Ray-cast = ray vs mesh AABB, then Möller–Trumbore against the skinned triangles; nearest hit wins. Hover → cursor grab.
pointerdown on a jelly → grab: collect particles within 0.95·scale of the hit point, weight = smoothstep falloff, store their offsets from the hit point. Each frame: lift eases 0→1 at 4/s; target plane y = hitY + 0.95·lift·(2−lift); new target = ray ∩ that plane, clamped to bounds; target += (new − target)·0.45. Release clears the grab; the jelly falls and wobbles.
pointerdown on empty space → orbit: yaw −= dx·0.006; pitch += dy·0.005, clamped 0.3–1.52.
Wheel: zoom *= exp(deltaY·0.0012), clamped 0.45–1.5 (preventDefault, passive:false). Two pointers: pinch zoom, cancel any grab.
Double-click a jelly: mark it dying.
Opening scene (type, x, z, facing where facing(dx,dz)=atan2(−dz,dx)); drop heights 1.6, 2.1, 2.6, … (0 if prefers-reduced-motion):
landscape: fruit(−4.4, 0.95, yaw 0.4); berry(−0.75,−0.75, facing(−0.28,1)); berry(0.75,0.95, facing(0.85,−0.62)); bloom(3.2,−2.35, facing(1,−0.42)); bloom(5.3,−0.75, facing(0.55,−1)); bloom(2.9,0.75, facing(1,−0.3))
portrait: bloom(−1.5,−3.9, facing(1,−0.5)); bloom(1.6,−3.0, facing(0.7,−1)); bloom(−0.3,−1.7, facing(1,−0.35)); berry(−1.2,0.5, facing(−0.3,1)); berry(1.2,1.3, facing(0.9,−0.5)); fruit(−0.3,3.9, 0.4)
Toolbar:
• Add pills: if 9 live jellies, mark the oldest dying; drop the new one from y 3.2 at the best of 18 random spots (farthest from other jellies), random yaw.
• Hard / Soft: switch mode; exactly one is .on.
• Scale: globalScale cycles 1 → 1.28 → 0.74; pill .on when ≠ 1.
• Shake: per jelly add velocity (±2.5, 5–8, ±2.5), a random spin about its centre, and ±1.25 jitter per particle.
• Reset: destroy all, rebuild the opening scene, restore camera and scale.
• Tags: toggle body class "tags". Each jelly has a fixed-position label: 10.5px, uppercase, letter-spacing .14em, color var(--ink), background rgba(255,255,255,.82), border 1px solid var(--line), radius 999px, padding 4px 10px 4px 8px, backdrop-filter blur(6px), 6px coloured dot, text like "KIWI 01" (counter per type, number in var(--muted)); opacity 0 → 1 over .25s; each frame translate to the projected point above the jelly, offset translate(-50%,-140%).
• View: cycle (pitch,yaw) 1.12/0 → 1.50/0 → 0.52/−0.55 → 0.85/0.7, zoom back to 1.
━━━━━━━━ 9. KNIFE ━━━━━━━━
Toggle: Knife pill, key K; Esc turns it off. While on: body class "knife" hides the canvas cursor (cursor:none), hint reads "KNIFE: Drag across a jelly to chop it", every one-finger drag is a cut stroke (no grabbing, no orbit, no double-click removal).
Model (flat-shaded: every quad has its own 4 vertices; local x along the blade, y from cutting edge 0 up to the spine, z thickness; subtract 1.3 from x so the pivot is mid-edge):
Blade: length 2.75 in 16 columns, half-thickness 0.045. Spine y = 1.26, sloping to 0.82 over the last 0.45. Edge y = 0, curving up 0.17 over the last 0.35. Each side: a bevel quad from the edge (z=0) up 0.32 to z=±0.045, then a flat quad up to the spine; plus spine, front and back caps. par.x = 0.
Handle: box x −1.5…0.14, y 0.86…1.26, z ±0.12. par.x = 1.
Material (uType 5): steel (.47,.49,.53) with faint brushed noise, bevel lighter (.80,.82,.86); wood dark brown with grain; three brass rivets on the handle sides at local x = −1.62, −2.02, −2.42, y 1.06, radius ≈.05. Lighting: alb*(.40+.70*wrap), Fresnel .22+.55*fr for steel and .05+.30*fr for wood, softer softbox highlights. Draw it in the shadow pass too (tint .12,.12,.16).
Placement each frame on the CPU: scale by vis, roll about local x, yaw about world y, translate; recompute normals; upload.
State: pos, yaw, roll, vis (eases toward 1 when on, 0 when off, rate 0.22), phase 'idle'|'chop', t, last pointer position.
Hover: target = ray ∩ plane y=2.3 under the pointer; yaw = camera yaw; roll = −1.25 (lying nearly flat, face toward the camera); ease 0.24.
Aim (while a stroke is held): A, B = stroke start/end projected onto plane y=0.35; dir = normalize(B−A); yaw = atan2(−dir.z, dir.x), adding π if that is more than 90° from the current yaw; target = midpoint of A,B at height 1.75; roll → 0 (edge down); ease 0.34. Draw the #cut guide line from press point to pointer.
Release with stroke ≥ 14px → chop: record every jelly hit by 29 rays sampled along the stroke; plane normal n = (dir.z, 0, −dir.x), d = n·A. Over 0.36 s: first 30% drop from 1.75 to 0.03 (quadratic ease-in); perform the cuts at 30%; hold until 55%; rise with ease-out; back to idle. Fade the guide line.
Cutting a body with world plane n·x = d:
1. Local plane: N = Rᵀ·n, D = (d − n·cm)/scale + N·c0 (R from the body's rot, c0 = cage rest centroid).
2. Signed distance per rest vertex (snap |dist|<1e-5 to +1e-5). If either side's maximum distance < 0.14 → refuse, show "That piece is too small to slice" for 2.6 s.
3. Clip every triangle into the two sides. For a triangle with one vertex v0 alone on its side: c1 on edge v0–v1, c2 on edge v0–v2; lone side gets (v0,c1,c2); other side gets (c1,v1,v2) and (c1,v2,c2). Interpolate pos and par linearly. Cache cut vertices per mesh edge.
4. Key each cut point by its rounded position (×20000) and record c1–c2 as neighbours. Walk these into outlines (start from points with a single neighbour first).
5. Project each outline (≥3 points) onto the plane and triangulate by ear clipping (make it counter-clockwise; skip reflex/collinear corners; if stuck, drop the flattest corner).
6. Add the cap to both sides with duplicated vertices: par = (rho, 0.5, par.z, 1) — for tulips keep par.y; for strawberries set rho to 1 where the original profile > 1.05. Wind the cap so its normal faces away from that side's solid (−N on the positive side, +N on the negative side).
7. For each side: build a new cage, a new body with scale, rot, seed and tag copied from the parent; particle world position = cm + R·((q + c0_new − c0_parent)·scale), y clamped ≥ 0; velocity = parent mean velocity ± n·0.7. Destroy the parent and free the GPU buffers of any cut-piece mesh.
Limit: if there are already 22 bodies, show "Too many pieces — press Reset to start fresh" and stop.
━━━━━━━━ FINAL SELF-CHECK BEFORE YOU ANSWER ━━━━━━━━
□ File is complete, one <script>, wrapped in an IIFE with 'use strict', no missing functions.
□ Every GLSL function is defined before use; every uniform used is declared; int uniform set with uniform1i.
□ Opening scene shows 1 kiwi, 2 strawberry halves, 3 tulips, with shadows, over the page gradient, heading visible behind them.
□ Dragging a jelly lifts and stretches it; releasing makes it wobble and settle; nothing sinks through the floor or flies away.
□ All eight dock buttons and five add pills do what section 8–9 says.
□ With Knife on, the cleaver follows the pointer, stands on edge along a dragged stroke, drops, and the jelly becomes two separate pieces showing their insides.
□ Layout works at 1280×720 and at 375×812.[/hidecontent]