Week 1: COMPUTER-CONTROLLED CUTTING

Modular clothes

Modular garments are clothing that can be assembled and taken apart in pieces without sewing the pieces together (definition).

Inspiration board: Refashion, Anrealage, Textile Studies, upcycled denim, modular pixel garments
Left to right: Refashion's fabric modules with reversible interfaces and its garment-pattern tool (Lin, Lukáč, Leake, UIST 2025); user prototypes; repurposed denim with grommet squares; modular pixel figures; Textile Studies, laser-cut lace on miniature mannequins, which is the visual target; a type-contour installation; the Refashion paper page; Anrealage's block silhouettes.

Three ideas run through these. Some garments are adjustable, where the wearer changes the shape, as in Lepore's drawstrings or Anrealage's blocks. Some are docked, where different materials are joined without sewing, as in sacai or Refashion. And some are modular in the strict sense, where one repeated piece makes the whole garment, as in Vogler's work, Variable Seams and Textile Studies.

Issey Miyake, triangulated white garment on the runway
Issey Miyake. One folded triangle repeated over the whole body; the surface is the structure. Source ↗
Ilaria Lepore, Hiroi Trousers
Lepore's Hiroi Trousers: oversized nylon with drawstrings that the wearer tightens or opens to change the silhouette. Adjustable. Source ↗
Kuon vintage boro patchwork pullover
Kuon's boro patchwork pullover: pieces of old indigo cloth joined into one garment. Modular by repair. Source ↗

Links: Hiroi Trousers · sacai · modular tote · Refashion · Anrealage / blocks · Textile Studies · Kuon · Vogler, Modular Fashion · Vogler, Fabricating human shapes · Variable Seams

Inspired by these, I decided to use acrylic, where the laser kerf actually matters, joined with press-fit connectors, plus a cardboard mannequin to wear it at tabletop scale like the Textile Studies pieces. So the plan became a modular skirt in acrylic and a mannequin to put it on.

I also built an interactive prototype of the mannequin and skirt in Three.js. Every slider is a real cut parameter, and the whole thing is a single file with no build step.

Interactive prototype: mannequin and skirt (Three.js)Open in new tab ↗
Source week02_preview_en.html · 326 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Week 2 — Waffle Mannequin + Modular Skirt Kit</title>
<style>
:root{
  --bg:#e9e6df; --fg:#1d1c1a; --muted:#6e6a62; --panel:#ffffffcc; --line:#d8d3c8; --accent:#c8412b;
  box-sizing:border-box; padding-top:env(safe-area-inset-top,0px); padding-bottom:env(safe-area-inset-bottom,0px);
}
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#17171a; --fg:#ece8df; --muted:#9a958b; --panel:#232326d9; --line:#3a3a40; } }
:root[data-theme="dark"]{ --bg:#17171a; --fg:#ece8df; --muted:#9a958b; --panel:#232326d9; --line:#3a3a40; }
html,body{height:100%;margin:0}
body{background:var(--bg);color:var(--fg);font:13px/1.4 -apple-system,system-ui,"Inter",sans-serif;overflow:hidden}
#c{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none}
#ui{position:absolute;left:12px;top:calc(12px + env(safe-area-inset-top,0px));width:262px;max-width:calc(100vw - 24px);background:var(--panel);backdrop-filter:blur(8px);border:1px solid var(--line);border-radius:8px;padding:10px 14px;max-height:calc(100% - 40px);overflow:auto}
#ui h1{font-size:13px;margin:0 0 6px}
#ui h2{font-size:11px;margin:10px 0 4px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;border-top:1px solid var(--line);padding-top:8px}
label{display:flex;justify-content:space-between;align-items:center;gap:8px;margin:4px 0;color:var(--muted)}
label span:first-child{white-space:nowrap}
input[type=range]{width:118px;accent-color:var(--accent)}
.v{min-width:34px;text-align:right;color:var(--fg);font-variant-numeric:tabular-nums}
.row{display:flex;gap:6px;margin-top:6px;flex-wrap:wrap}
button{background:transparent;border:1px solid var(--line);color:var(--fg);border-radius:6px;padding:3px 8px;font:inherit;cursor:pointer}
button.on{border-color:var(--accent);color:var(--accent)}
#bom{margin-top:8px;padding-top:8px;border-top:1px solid var(--line);color:var(--muted);font-size:12px;line-height:1.6}
#hint{position:absolute;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));color:var(--muted);font-size:12px}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
  <h1>Waffle Mannequin + Modular Skirt Kit</h1>
  <p style="margin:0 0 6px;color:var(--muted);font-size:12px">HTMAA Week 2 · computer-controlled cutting. A laser-cut cardboard dress form (two families of slotted slices) wearing a press-fit skirt of triangular acrylic panels. Everything is parametric; the sliders are the real cut parameters.</p>

  <h2>Mannequin (waffle slices)</h2>
  <label><span>Scale (1 = life size)</span><input id="scale" type="range" min="30" max="70" value="40"><span class="v" id="scale_v">0.40</span></label>
  <label><span>Slice pitch mm</span><input id="pitch" type="range" min="12" max="40" value="18"><span class="v" id="pitch_v">18</span></label>
  <label><span>Tilt, X family °</span><input id="tx" type="range" min="-30" max="30" value="15"><span class="v" id="tx_v">15</span></label>
  <label><span>Tilt, Y family °</span><input id="ty" type="range" min="-30" max="30" value="-10"><span class="v" id="ty_v">-10</span></label>

  <h2>Skirt (triangular press-fit kit)</h2>
  <label><span>Rings</span><input id="rings" type="range" min="1" max="5" value="3"><span class="v" id="rings_v">3</span></label>
  <label><span>Triangles per ring N</span><input id="n" type="range" min="6" max="24" value="10"><span class="v" id="n_v">10</span></label>
  <label><span>Flare mm / ring</span><input id="flare" type="range" min="0" max="60" value="10"><span class="v" id="flare_v">10</span></label>
  <label><span>Ring height mm</span><input id="h" type="range" min="20" max="80" value="28"><span class="v" id="h_v">28</span></label>
  <label><span>Connector fin mm</span><input id="fin" type="range" min="4" max="24" value="10"><span class="v" id="fin_v">10</span></label>
  <label><span>Lattice divisions</span><input id="lace" type="range" min="0" max="8" value="4"><span class="v" id="lace_v">4</span></label>
  <label><span>Cut-through %</span><input id="cut" type="range" min="0" max="100" value="30"><span class="v" id="cut_v">30</span></label>

  <div class="row">
    <button id="mmat">Cardboard</button>
    <button id="mcol">B/W: per family</button>
  </div>
  <div class="row">
    <button id="showM" class="on">Mannequin</button>
    <button id="showS" class="on">Skirt</button>
    <button id="con" class="on">Connectors</button>
    <button id="spin" class="on">Spin</button>
  </div>
  <div id="bom"></div>
</div>
<div id="hint">Drag to orbit · wheel to zoom</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
  // ---------------- shared kit constants (mm at output scale) ----------------
  var T_CARD = 4.0;                        // cardboard
  var T_ACR = 3.0, SW = 2.95, SD = 5, GAP = 1.5, SLOTS = 2;   // acrylic skirt (tabletop scale)
  var NEXP = 2.4;
  // life-size profile: z, circumference, depth/width
  var PROFILE = [[0,940,0.72],[90,880,0.72],[200,680,0.68],[300,760,0.72],[360,880,0.80],[430,840,0.62],[500,860,0.48],[545,380,0.90],[600,360,0.90]];

  // ---------------- PCHIP (Fritsch–Carlson) ----------------
  function pchip(xs, ys){
    var n=xs.length, h=[], d=[], m=new Array(n);
    for (var i=0;i<n-1;i++){ h.push(xs[i+1]-xs[i]); d.push((ys[i+1]-ys[i])/h[i]); }
    for (i=1;i<n-1;i++){
      if (d[i-1]*d[i]<=0) m[i]=0;
      else { var w1=2*h[i]+h[i-1], w2=h[i]+2*h[i-1]; m[i]=(w1+w2)/(w1/d[i-1]+w2/d[i]); }
    }
    m[0]=d[0]; m[n-1]=d[n-2];
    return function(x){
      if (x<=xs[0]) return ys[0]; if (x>=xs[n-1]) return ys[n-1];
      var k=0; while (x>xs[k+1]) k++;
      var t=(x-xs[k])/h[k], t2=t*t, t3=t2*t;
      return ys[k]*(2*t3-3*t2+1) + h[k]*m[k]*(t3-2*t2+t) + ys[k+1]*(-2*t3+3*t2) + h[k]*m[k+1]*(t3-t2);
    };
  }
  function body(scale){
    var zs=[], as=[], bs=[];
    PROFILE.forEach(function(p){
      var c=p[1]*scale, r=p[2];
      var f=function(a){ return Math.PI*(3*(a+r*a)-Math.sqrt((3*a+r*a)*(a+3*r*a)))-c; };
      var lo=1, hi=1000; for (var i=0;i<60;i++){ var mid=(lo+hi)/2; if (f(mid)<0) lo=mid; else hi=mid; }
      zs.push(p[0]*scale); as.push(lo); bs.push(lo*r);
    });
    return {a:pchip(zs,as), b:pchip(zs,bs), z0:zs[0], z1:zs[zs.length-1]};
  }
  function inside(B,x,y,z){ if (z<B.z0||z>B.z1) return false; var A=B.a(z), Bb=B.b(z); return Math.pow(Math.abs(x/A),NEXP)+Math.pow(Math.abs(y/Bb),NEXP)<1; }

  // ---------------- scene ----------------
  var canvas=document.getElementById('c');
  var renderer=new THREE.WebGLRenderer({canvas:canvas,antialias:true,alpha:true}); renderer.setPixelRatio(Math.min(devicePixelRatio,2));
  var scene=new THREE.Scene(); var camera=new THREE.PerspectiveCamera(36,1,1,5000);
  scene.add(new THREE.HemisphereLight(0xffffff,0x777766,0.9));
  var sun=new THREE.DirectionalLight(0xffffff,0.8); sun.position.set(300,500,400); scene.add(sun);
  var sun2=new THREE.DirectionalLight(0xffffff,0.3); sun2.position.set(-400,200,-300); scene.add(sun2);
  var gM=new THREE.Group(), gS=new THREE.Group(); scene.add(gM); scene.add(gS);
  // mannequin materials: white / black, cardboard (matte, kraft edge) or acrylic (gloss)
  var mAcrylic=false, colorMode='family';          // 'family' = X white, Y black · 'face' = each sheet white one side, black the other
  var mWhite, mBlack, mEdge;
  function makeMannequinMats(){
    if (mAcrylic){
      mWhite=new THREE.MeshStandardMaterial({color:0xf2f0ea,roughness:0.25,metalness:0.05});
      mBlack=new THREE.MeshStandardMaterial({color:0x1c1c1e,roughness:0.25,metalness:0.1});
      mEdge =new THREE.MeshStandardMaterial({color:0x9a9a9a,roughness:0.2,metalness:0.1});
    } else {
      mWhite=new THREE.MeshStandardMaterial({color:0xf4f1ea,roughness:0.95});
      mBlack=new THREE.MeshStandardMaterial({color:0x222222,roughness:0.95});
      mEdge =new THREE.MeshStandardMaterial({color:0xb99b6b,roughness:1.0});     // kraft core
    }
  }
  makeMannequinMats();
  // split an extruded slab into groups: +normal cap, -normal cap, side walls
  function slabGeometry(geo){
    var pos=geo.attributes.position.array, nor=geo.attributes.normal.array, groups=[[],[],[]];
    for (var i=0;i<pos.length;i+=9){
      var nz=nor[i+2]; var g = Math.abs(nz)>0.9 ? (nz>0?0:1) : 2;
      for (var k=0;k<9;k++) groups[g].push(pos[i+k]);
    }
    var out=new THREE.BufferGeometry(), all=[], start=0;
    groups.forEach(function(arr,gi){ all=all.concat(arr); out.addGroup(start/3, arr.length/3, gi); start+=arr.length; });
    out.setAttribute('position',new THREE.Float32BufferAttribute(all,3)); out.computeVertexNormals();
    return out;
  }
  var matCon=new THREE.MeshStandardMaterial({color:0x2a2a2c,roughness:0.35,metalness:0.1});
  var showM=true, showS=true, showCon=true, spin=true;
  var $=function(id){ return document.getElementById(id); };
  function val(id){ return +$(id).value; }

  // ---------------- mannequin ----------------
  function frame(fam, tiltDeg){
    var t=tiltDeg*Math.PI/180;
    if (fam==='x') return {n:new THREE.Vector3(Math.cos(t),0,-Math.sin(t)), eu:new THREE.Vector3(0,1,0), ev:new THREE.Vector3(Math.sin(t),0,Math.cos(t))};
    return {n:new THREE.Vector3(0,Math.cos(t),-Math.sin(t)), eu:new THREE.Vector3(1,0,0), ev:new THREE.Vector3(0,Math.sin(t),Math.cos(t))};
  }
  // outline of plane n·p=d: eu is axis aligned so each v gives a fixed (x or y, z) and a chord along eu
  function sliceOutlines(B, fam, F, d){
    var runs=[], cur=null, step=1.0;
    var vmin=-(B.z1-B.z0)*1.2, vmax=(B.z1-B.z0)*1.6;
    for (var v=vmin; v<=vmax; v+=step){
      var p=F.n.clone().multiplyScalar(d).add(F.ev.clone().multiplyScalar(v));   // u=0 point
      var z=p.z, e=null;
      if (z>=B.z0 && z<=B.z1){
        var A=B.a(z), Bb=B.b(z);
        if (fam==='x'){ var k=1-Math.pow(Math.abs(p.x/A),NEXP); if (k>0) e=Bb*Math.pow(k,1/NEXP); }
        else          { var k2=1-Math.pow(Math.abs(p.y/Bb),NEXP); if (k2>0) e=A*Math.pow(k2,1/NEXP); }
      }
      if (e!==null && e>0.5){ if(!cur){cur=[];runs.push(cur);} cur.push([v,e]); } else cur=null;
    }
    return runs.filter(function(r){ return r.length>=3; }).map(function(r){
      var pts=r.map(function(q){return [q[1],q[0]];}).concat(r.slice().reverse().map(function(q){return [-q[1],q[0]];}));
      return pts;   // [u,v] CCW-ish
    });
  }
  function offsets(ext, tilt, pitch, B){
    var span=ext+(B.z1-B.z0)*Math.abs(Math.sin(tilt*Math.PI/180))/2, ds=[];
    for (var d=-span+pitch/2; d<span; d+=pitch) ds.push(d);
    var c=(ds[0]+ds[ds.length-1])/2; return ds.map(function(d){return d-c;});
  }
  function buildMannequin(){
    while (gM.children.length) gM.remove(gM.children[0]);
    var S=val('scale')/100, pitch=val('pitch'), tx=val('tx'), ty=val('ty');
    var B=body(S);
    var amax=0,bmax=0; for (var z=B.z0; z<=B.z1; z+=2){ amax=Math.max(amax,B.a(z)); bmax=Math.max(bmax,B.b(z)); }
    var Fx=frame('x',tx), Fy=frame('y',ty), zmid=(B.z0+B.z1)/2;
    var xs=offsets(amax,tx,pitch,B).map(function(d){return d+Fx.n.z*zmid;});
    var ys=offsets(bmax,ty,pitch,B).map(function(d){return d+Fy.n.z*zmid;});
    // joints (for BOM and for dropping unjoined slices)
    var l=new THREE.Vector3().crossVectors(Fx.n,Fy.n).normalize(); if (l.z<0) l.negate();
    var jointsX={}, jointsY={}, nJ=0;
    var M=new THREE.Matrix3().set(Fx.n.x,Fx.n.y,Fx.n.z, Fy.n.x,Fy.n.y,Fy.n.z, l.x,l.y,l.z);
    var Minv=new THREE.Matrix3().copy(M).invert();
    xs.forEach(function(dx,i){ ys.forEach(function(dy,j){
      var p0=new THREE.Vector3(dx,dy,0).applyMatrix3(Minv); var ins=[];
      for (var t=-800;t<=800;t+=2){ var q=p0.clone().add(l.clone().multiplyScalar(t)); if (inside(B,q.x,q.y,q.z)) ins.push(t); }
      if (ins.length>=2 && ins[ins.length-1]-ins[0] >= 20){ jointsX[i]=(jointsX[i]||0)+1; jointsY[j]=(jointsY[j]||0)+1; nJ++; }
    });});
    var count={x:0,y:0};
    [['x',xs,Fx,jointsX],['y',ys,Fy,jointsY]].forEach(function(fam){
      fam[1].forEach(function(d,i){
        if ((fam[3][i]||0)<2) return;
        sliceOutlines(B,fam[0],fam[2],d).forEach(function(pts){
          var sh=new THREE.Shape(); sh.moveTo(pts[0][0],pts[0][1]); for (var k=1;k<pts.length;k++) sh.lineTo(pts[k][0],pts[k][1]);
          var geo=slabGeometry(new THREE.ExtrudeGeometry(sh,{depth:T_CARD,bevelEnabled:false}).translate(0,0,-T_CARD/2));
          var m=new THREE.Matrix4().makeBasis(fam[2].eu,fam[2].ev,fam[2].n); m.setPosition(fam[2].n.clone().multiplyScalar(d));
          var mats = colorMode==='face' ? [mWhite,mBlack,mEdge]
                   : (fam[0]==='x' ? [mWhite,mWhite,mEdge] : [mBlack,mBlack,mEdge]);
          var mesh=new THREE.Mesh(geo,mats); mesh.applyMatrix4(m); gM.add(mesh);
        });
        count[fam[0]]++;
      });
    });
    // z-up -> y-up
    gM.rotation.x=-Math.PI/2;
    return {B:B, S:S, nx:count.x, ny:count.y, nJ:nJ};
  }

  // ---------------- skirt (from modkit preview) ----------------
  function notchedShape(p2){
    var s=new THREE.Shape();
    for (var i=0;i<3;i++){
      var a=p2[i], b=p2[(i+1)%3], dx=b[0]-a[0], dy=b[1]-a[1], L=Math.hypot(dx,dy); dx/=L; dy/=L; var nx=-dy, ny=dx;
      if (i===0) s.moveTo(a[0],a[1]);
      for (var k=1;k<=SLOTS;k++){ var t=L*k/(SLOTS+1), cx=a[0]+dx*t, cy=a[1]+dy*t;
        s.lineTo(cx-dx*SW/2,cy-dy*SW/2); s.lineTo(cx-dx*SW/2+nx*SD,cy-dy*SW/2+ny*SD); s.lineTo(cx+dx*SW/2+nx*SD,cy+dy*SW/2+ny*SD); s.lineTo(cx+dx*SW/2,cy+dy*SW/2); }
      s.lineTo(b[0],b[1]);
    }
    return s;
  }
  function lerp2(a,b,c,s,t){ return [a[0]+(b[0]-a[0])*s+(c[0]-a[0])*t, a[1]+(b[1]-a[1])*s+(c[1]-a[1])*t]; }
  function inTri(x,y,q,m){ for (var i=0;i<3;i++){ var a=q[i],b=q[(i+1)%3],ex=b[0]-a[0],ey=b[1]-a[1],L=Math.hypot(ex,ey); if ((ex*(y-a[1])-ey*(x-a[0]))/L<m) return false; } return true; }
  function addCuts(shape,p2,div,cutFrac,seed){
    if (div<=0||cutFrac<=0) return;
    var a=p2[0],b=p2[1],c=p2[2], margin=SD+1.5, inset=0.9;
    var rnd=function(){ seed=(seed*9301+49297)%233280; return seed/233280; };
    for (var i=0;i<div;i++) for (var j=0;j<div-i;j++){
      var P0=lerp2(a,b,c,i/div,j/div),P1=lerp2(a,b,c,(i+1)/div,j/div),P2=lerp2(a,b,c,i/div,(j+1)/div);
      var cx=(P0[0]+P1[0]+P2[0])/3, cy=(P0[1]+P1[1]+P2[1])/3;
      if (!inTri(cx,cy,p2,margin)||rnd()>cutFrac) continue;
      var pts=[P0,P1,P2].map(function(p){var vx=cx-p[0],vy=cy-p[1],l=Math.hypot(vx,vy)||1;return [p[0]+vx/l*inset,p[1]+vy/l*inset];});
      var h=new THREE.Path(); h.moveTo(pts[0][0],pts[0][1]); h.lineTo(pts[2][0],pts[2][1]); h.lineTo(pts[1][0],pts[1][1]); h.closePath(); shape.holes.push(h);
    }
  }
  function engraveTexture(q,div){
    var minx=Math.min(q[0][0],q[1][0],q[2][0]),maxx=Math.max(q[0][0],q[1][0],q[2][0]),miny=Math.min(q[0][1],q[1][1],q[2][1]),maxy=Math.max(q[0][1],q[1][1],q[2][1]);
    var px=12, W=Math.ceil((maxx-minx)*px)||1, H=Math.ceil((maxy-miny)*px)||1;
    var cv=document.createElement('canvas'); cv.width=W; cv.height=H; var g=cv.getContext('2d');
    g.fillStyle='#222224'; g.fillRect(0,0,W,H);
    if (div>0){ g.strokeStyle='#9a9a9e'; g.lineWidth=0.25*px;
      for (var i=0;i<3;i++){ var a=q[i],b=q[(i+1)%3],c=q[(i+2)%3],ex=b[0]-a[0],ey=b[1]-a[1],L=Math.hypot(ex,ey); ex/=L; ey/=L; var nx=-ey,ny=ex,hgt=(c[0]-a[0])*nx+(c[1]-a[1])*ny;
        for (var k=0;k<=div;k++){ var d=hgt*k/div,ox=a[0]+nx*d,oy=a[1]+ny*d; g.beginPath(); g.moveTo((ox-ex*500-minx)*px,H-(oy-ey*500-miny)*px); g.lineTo((ox+ex*500-minx)*px,H-(oy+ey*500-miny)*px); g.stroke(); } } }
    var tex=new THREE.CanvasTexture(cv); tex.repeat.set(1/(maxx-minx),1/(maxy-miny)); tex.offset.set(-minx/(maxx-minx),-miny/(maxy-miny)); return tex;
  }
  function buildSkirt(info){
    while (gS.children.length) gS.remove(gS.children[0]);
    var B=info.B, rings=val('rings'), N=val('n'), flare=val('flare'), H=val('h'), FIN=val('fin'), div=val('lace'), cutF=val('cut')/100;
    var zw=200*info.S;                                               // waist height
    var r0=(B.a(zw)+B.b(zw))/2+2;                                    // waistband radius sits on the waist
    function V(k,i){ var r=r0+k*flare, a=(i+(k%2)*0.5)*2*Math.PI/N; return new THREE.Vector3(r*Math.cos(a), zw-k*H, r*Math.sin(a)); }
    var faces=[];
    for (var k=0;k<rings;k++) for (var i=0;i<N;i++){ var o=k%2, A=V(k,i),Bv=V(k,i+1),C=V(k+1,i+o),D=V(k+1,i+o+1); faces.push([A,C,Bv]); faces.push([Bv,C,D]); }
    var edgeMap={}, angles={}, nCon=0;
    faces.forEach(function(f,fi){
      var a=f[0],b=f[1],c=f[2], n=new THREE.Vector3().subVectors(b,a).cross(new THREE.Vector3().subVectors(c,a)).normalize();
      var cen=new THREE.Vector3().add(a).add(b).add(c).multiplyScalar(1/3);
      if (n.dot(new THREE.Vector3(cen.x,0,cen.z))<0){ n.negate(); var t=f[1]; f[1]=f[2]; f[2]=t; a=f[0]; b=f[1]; c=f[2]; }
      f.n=n; f.cen=cen;
      var u=new THREE.Vector3().subVectors(b,a).normalize(), v=new THREE.Vector3().crossVectors(n,u).normalize();
      var p2=[a,b,c].map(function(p){ var d=new THREE.Vector3().subVectors(p,cen); return [d.dot(u),d.dot(v)]; });
      var ar=(p2[1][0]-p2[0][0])*(p2[2][1]-p2[0][1])-(p2[2][0]-p2[0][0])*(p2[1][1]-p2[0][1]); if (ar<0) p2.reverse();
      var q=p2.map(function(p,i){ var pr=p2[(i+2)%3],nx=p2[(i+1)%3],e1=[pr[0]-p[0],pr[1]-p[1]],e2=[nx[0]-p[0],nx[1]-p[1]],l1=Math.hypot(e1[0],e1[1]),l2=Math.hypot(e2[0],e2[1]);
        e1=[e1[0]/l1,e1[1]/l1]; e2=[e2[0]/l2,e2[1]/l2]; var bis=[e1[0]+e2[0],e1[1]+e2[1]],lb=Math.hypot(bis[0],bis[1]); bis=[bis[0]/lb,bis[1]/lb];
        var half=Math.acos(Math.max(-1,Math.min(1,e1[0]*e2[0]+e1[1]*e2[1])))/2, d=(GAP/2)/Math.sin(half); return [p[0]+bis[0]*d,p[1]+bis[1]*d]; });
      var ringIdx=Math.floor(fi/(2*N)), w=rings>1?(ringIdx+0.5)/rings:1;
      var shape=notchedShape(q); addCuts(shape,q,div,cutF*w*1.6,fi*7+13);
      var geo=new THREE.ExtrudeGeometry(shape,{depth:T_ACR,bevelEnabled:false}); geo.translate(0,0,-T_ACR/2);
      var m=new THREE.Matrix4().makeBasis(u,v,n); m.setPosition(cen);
      var mesh=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({map:engraveTexture(q,div),roughness:0.35,metalness:0.1})); mesh.applyMatrix4(m); gS.add(mesh);
      for (var i=0;i<3;i++){ var pa=f[i],pb=f[(i+1)%3]; var key=[pa,pb].map(function(p){return p.x.toFixed(2)+','+p.y.toFixed(2)+','+p.z.toFixed(2);}).sort().join('|'); (edgeMap[key]=edgeMap[key]||[]).push({f:f,pa:pa,pb:pb}); }
    });
    if (showCon) Object.keys(edgeMap).forEach(function(key){
      var list=edgeMap[key]; if (list.length!==2) return;
      var f1=list[0].f,f2=list[1].f,pa=list[0].pa,pb=list[0].pb, e=new THREE.Vector3().subVectors(pb,pa), L=e.length(); e.normalize();
      var dih=Math.round(Math.acos(Math.max(-1,Math.min(1,f1.n.dot(f2.n))))*180/Math.PI); angles[dih]=(angles[dih]||0)+1;
      for (var k=1;k<=SLOTS;k++){ var c=pa.clone().add(e.clone().multiplyScalar(L*k/(SLOTS+1)));
        [f1,f2].forEach(function(f){ var u=new THREE.Vector3().subVectors(f.cen,c); u.sub(e.clone().multiplyScalar(u.dot(e))).normalize(); var armL=SD+GAP/2;
          var geo=new THREE.BoxGeometry(armL,FIN,SW), m=new THREE.Matrix4().makeBasis(u,f.n,e); m.setPosition(c.clone().add(u.clone().multiplyScalar(armL/2)));
          var mesh=new THREE.Mesh(geo,matCon); mesh.applyMatrix4(m); gS.add(mesh); });
        nCon++; }
    });
    var angTxt=Object.keys(angles).sort(function(a,b){return a-b;}).map(function(a){return a+'°×'+angles[a];}).join('、');
    var eTop=V(0,0).distanceTo(V(0,1)), eBot=V(rings,0).distanceTo(V(rings,1));
    return {faces:faces.length, nCon:nCon, angTxt:angTxt, eTop:eTop, eBot:eBot, r0:r0, hem:2*(r0+rings*flare), bottom:zw-rings*H};
  }

  var info=null, center=150;
  function build(){
    info=buildMannequin(); var sk=buildSkirt(info);
    gM.visible=showM; gS.visible=showS;
    var B=info.B; center=(B.z1+Math.min(0,sk.bottom))/2;
    $('bom').innerHTML=
      '<b>Mannequin</b> height '+Math.round(B.z1)+' mm · '+info.nx+' X + '+info.ny+' Y slices · '+info.nJ+' joints<br>'+
      '<b>Skirt</b> '+sk.faces+' panels (edges ≈ '+Math.round(sk.eTop)+'–'+Math.round(sk.eBot)+' mm) · '+sk.nCon+' connectors<br>'+
      'Dihedral angles: '+(sk.angTxt||'—')+'<br>'+
      'Waist radius '+Math.round(sk.r0)+' mm · hem Ø '+Math.round(sk.hem)+' mm'+
      (sk.eTop<20?'<br><span style="color:var(--accent)">⚠ Edges under 20 mm: no room for slots. Lower N or raise ring height</span>':'');
  }
  ['scale','pitch','tx','ty','rings','n','flare','h','fin','lace','cut'].forEach(function(id){
    $(id).addEventListener('input',function(){ $(id+'_v').textContent = id==='scale' ? (this.value/100).toFixed(2) : this.value; build(); });
  });
  $('mmat').addEventListener('click',function(){ mAcrylic=!mAcrylic; this.textContent=mAcrylic?'Acrylic':'Cardboard'; makeMannequinMats(); build(); });
  $('mcol').addEventListener('click',function(){ colorMode = colorMode==='family' ? 'face' : 'family'; this.textContent = colorMode==='family' ? 'B/W: per family' : 'B/W: two-faced sheets'; build(); });
  $('showM').addEventListener('click',function(){ showM=!showM; this.classList.toggle('on',showM); gM.visible=showM; });
  $('showS').addEventListener('click',function(){ showS=!showS; this.classList.toggle('on',showS); gS.visible=showS; });
  $('con').addEventListener('click',function(){ showCon=!showCon; this.classList.toggle('on',showCon); build(); });
  $('spin').addEventListener('click',function(){ spin=!spin; this.classList.toggle('on',spin); });

  var theta=0.6, phi=1.25, dist=900, drag=false, lx=0, ly=0;
  canvas.addEventListener('pointerdown',function(e){drag=true;lx=e.clientX;ly=e.clientY;});
  window.addEventListener('pointerup',function(){drag=false;});
  window.addEventListener('pointermove',function(e){ if(!drag)return; theta-=(e.clientX-lx)*0.006; phi=Math.max(0.2,Math.min(2.6,phi-(e.clientY-ly)*0.006)); lx=e.clientX; ly=e.clientY; });
  canvas.addEventListener('wheel',function(e){ e.preventDefault(); dist=Math.max(200,Math.min(3000,dist*(1+e.deltaY*0.001))); },{passive:false});
  function resize(){ var w=canvas.clientWidth,h=canvas.clientHeight; if(canvas.width!==Math.round(w*renderer.getPixelRatio())){ renderer.setSize(w,h,false); camera.aspect=w/h; camera.updateProjectionMatrix(); } }
  function loop(){ resize(); if(spin&&!drag) theta+=0.003;
    camera.position.set(dist*Math.sin(phi)*Math.cos(theta), center+dist*Math.cos(phi), dist*Math.sin(phi)*Math.sin(theta)); camera.lookAt(0,center,0);
    renderer.render(scene,camera); requestAnimationFrame(loop); }
  build(); loop();
})();
</script>
</body>
</html>

The triangle kit

I started with panels made from triangles, written in Python with Claude as modkit.py.

Kit parts: tri, tile, half, dart, connectors
The kit. tri is an equilateral triangle with slots on every edge; tile, half and dart are a square, a half square and a trapezoid, where the dart gives curvature by its shape; con_0 is a straight connector for coplanar panels and con_<angle> an angled one that sets the dihedral angle between panels; fit_test is a strip of slots stepping 0.1 mm around the material thickness.

The generator is Python with shapely and ezdxf, and writes a DXF with CUT and ENGRAVE layers.

modkit.py 227 lines · download
#!/usr/bin/env python3
"""
modkit.py — parametric press-fit construction kit for modular garments
HTMAA Week 2 (computer-controlled cutting)

Parts
  tri       : equilateral triangle panel (triangulated surfaces curve/drape)
  tile      : square panel, N slots per edge
  half      : W x W/2 panel
  dart      : trapezoid panel (top W, bottom W*dart_ratio) -> introduces curvature
  con_0     : straight connector (coplanar tiles)
  con_<deg> : angled connector -> dihedral between tiles (non-flat surfaces)
  fit_test  : strip of slots at varying width to calibrate kerf/fit

Every slot on every part shares one width, so any connector fits any panel.

usage:  python3 modkit.py               -> out/*.dxf, out/*.svg, out/sheet.dxf
        edit P below, or pass overrides: python3 modkit.py thickness=3 kerf=0.15
"""
import math, os, sys
import ezdxf
from shapely.geometry import Polygon, Point, MultiPoint, box
from shapely.geometry.polygon import orient
from shapely.affinity import rotate, translate
from shapely.ops import unary_union

# --------------------------------------------------------------------------- #
P = dict(
    thickness   = 4.0,    # mm  material thickness (cardboard ~4, acrylic 3)
    kerf        = 0.20,   # mm  measured with fit_test (cardboard ~0.2, acrylic ~0.15)
    fit         = -0.10,  # mm  panel slots: negative = tighter press-fit, 0 = slip fit
    con_fit     = None,   # mm  connector slots; None = same as fit (they can differ: panel and connector grip differently)
    W           = 60.0,   # mm  tile side length
    slots       = 2,      # slots per edge
    slot_depth  = 8.0,    # mm  how far a connector sinks into a panel
    con_height  = 18.0,   # mm  connector height (the "fin")
    con_gap     = 2.0,    # mm  space left between two joined panels
    dart_ratio  = 0.6,    # bottom/top width of the trapezoid panel
    angles      = [30, 60, 90],   # angled connectors to generate (deg)
    corner_r    = 2.0,    # mm  round outer corners (0 = sharp)
    counts      = dict(tri=12, tile=6, half=4, dart=4, con_0=20, con_30=6, con_60=6, con_90=6),
    sheet_w     = 600, sheet_h = 400, margin = 6,   # xTool bed-ish, mm
    fit_range   = [-0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3],   # slots on the fit_test strip
)

def slot_w(fit=None):
    """drawn slot width so that the *physical* slot = thickness + fit"""
    f = P["fit"] if fit is None else fit
    return P["thickness"] + f - P["kerf"]

def con_slot_w():
    return slot_w(P["fit"] if P["con_fit"] is None else P["con_fit"])

# --------------------------------------------------------------------------- #
def notched(poly, n_per_edge):
    """cut n perpendicular slots into every edge of a sharp convex polygon,
    then round the remaining outer corners"""
    sw, sd = slot_w(), P["slot_depth"]
    poly = orient(poly, 1.0)                      # CCW -> inward = left normal
    pts = list(poly.exterior.coords)[:-1]
    cutters = []
    for i in range(len(pts)):
        (x0, y0), (x1, y1) = pts[i], pts[(i + 1) % len(pts)]
        ex, ey = x1 - x0, y1 - y0
        L = math.hypot(ex, ey)
        ang = math.degrees(math.atan2(ey, ex))
        for k in range(n_per_edge):
            t = (k + 1) / (n_per_edge + 1)
            cx, cy = x0 + ex * t, y0 + ey * t
            # rectangle along edge (width sw), extending inward sd and outward 1mm
            r = box(-sw / 2, -1.0, sw / 2, sd)          # local: +y = inward
            r = rotate(r, ang, origin=(0, 0))          # local +x -> edge dir, +y -> inward
            cutters.append(translate(r, cx, cy))
    return rounded(poly, P["corner_r"]).difference(unary_union(cutters))

def rounded(poly, r):
    return poly.buffer(-r).buffer(r, join_style=1) if r > 0 else poly

def tile():
    W = P["W"]
    return notched(box(0, 0, W, W), P["slots"])

def tri():
    W = P["W"]
    return notched(Polygon([(0, 0), (W, 0), (W / 2, W * math.sqrt(3) / 2)]), P["slots"])

def half():
    W = P["W"]
    return notched(box(0, 0, W, W / 2), P["slots"])

def dart():
    W, k = P["W"], P["dart_ratio"]
    dx = W * (1 - k) / 2
    poly = Polygon([(dx, 0), (W - dx, 0), (W, W), (0, W)])
    return notched(poly, P["slots"])

def connector(angle_deg=0):
    """two arms, each slot_depth+gap/2 long, meeting at the hinge line"""
    sw, sd, h, g = con_slot_w(), P["slot_depth"], P["con_height"], P["con_gap"]
    L = sd + g / 2
    def arm():
        a = box(0, -h / 2, L, h / 2)
        s = box(L - sd, -sw / 2, L + 1, sw / 2)   # slot enters from the far end
        return a.difference(s)
    a1 = rotate(arm(), 180 - angle_deg / 2, origin=(0, 0))   # points left-ish
    a2 = rotate(arm(), angle_deg / 2, origin=(0, 0))          # points right-ish
    # fill the inner wedge so the joint isn't a pinch point
    # fill the V-gap on the outer side of the hinge with a wedge
    ends = [(0, -h / 2), (0, h / 2)]
    corners = [rotate(Point(p), 180 - angle_deg / 2, origin=(0, 0)) for p in ends] + \
              [rotate(Point(p), angle_deg / 2, origin=(0, 0)) for p in ends] + [Point(0, 0)]
    hub = MultiPoint(corners).convex_hull if angle_deg else Polygon()
    c = unary_union([a1, a2, hub])
    return rounded(c, min(P["corner_r"], 1.0))

def fit_test():
    """one strip, slots stepping fit from -0.4 to +0.3 mm around thickness"""
    t, k, sd = P["thickness"], P["kerf"], P["slot_depth"]
    fits = P.get("fit_range", [-0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3])
    pitch = 12
    strip = box(0, 0, pitch * len(fits) + 6, sd + 10)
    cutters = []
    for i, f in enumerate(fits):
        w = t + f - k
        x = 6 + pitch * i
        cutters.append(box(x - w / 2, sd + 10 - sd - 1, x + w / 2, sd + 11))
    return strip.difference(unary_union(cutters)), fits

# --------------------------------------------------------------------------- #
def rings(poly):
    if poly.geom_type == "MultiPolygon":
        poly = max(poly.geoms, key=lambda g: g.area)
    yield list(poly.exterior.coords)
    for i in poly.interiors:
        yield list(i.coords)

def write_dxf(parts, path):
    """parts: list of (polygon, dx, dy)"""
    doc = ezdxf.new("R2010", setup=True)
    doc.header["$INSUNITS"] = 4              # mm
    doc.layers.add("CUT", color=1)
    doc.layers.add("ENGRAVE", color=5)
    msp = doc.modelspace()
    for poly, dx, dy, label in parts:
        for ring in rings(translate(poly, dx, dy)):
            msp.add_lwpolyline(ring, close=True, dxfattribs={"layer": "CUT"})
        if label:
            b = translate(poly, dx, dy).bounds
            msp.add_text(label, height=3, dxfattribs={"layer": "ENGRAVE"}).set_placement(
                ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2), align=ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER)
    doc.saveas(path)

def write_svg(parts, path, w, h):
    out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}mm" height="{h}mm" viewBox="0 0 {w} {h}">',
           f'<rect width="{w}" height="{h}" fill="white"/>']
    for poly, dx, dy, label in parts:
        for ring in rings(translate(poly, dx, dy)):
            d = "M " + " L ".join(f"{x:.3f},{h - y:.3f}" for x, y in ring) + " Z"
            out.append(f'<path d="{d}" fill="none" stroke="red" stroke-width="0.2"/>')
        if label:
            b = translate(poly, dx, dy).bounds
            out.append(f'<text x="{(b[0]+b[2])/2:.1f}" y="{h-(b[1]+b[3])/2+1:.1f}" font-size="3" '
                       f'text-anchor="middle" fill="blue" font-family="sans-serif">{label}</text>')
    out.append("</svg>")
    open(path, "w").write("\n".join(out))

def layout(items):
    """shelf packing onto as many sheets as needed. items: list of (name, polygon) -> list of sheets"""
    m, SW, SH = P["margin"], P["sheet_w"], P["sheet_h"]
    x, y, shelf = m, m, 0
    sheets, placed = [], []
    for name, poly in items:
        b = poly.bounds
        pw, ph = b[2] - b[0], b[3] - b[1]
        if x + pw > SW - m:
            x, y, shelf = m, y + shelf + m, 0
        if y + ph > SH - m:
            sheets.append(placed); placed = []; x, y, shelf = m, m, 0
        placed.append((poly, x - b[0], y - b[1], name if name.startswith("R") else None))   # ring labels engraved
        x += pw + m
        shelf = max(shelf, ph)
    sheets.append(placed)
    return sheets

# --------------------------------------------------------------------------- #
def main():
    for arg in sys.argv[1:]:                       # cli overrides: key=value
        k, v = arg.split("=", 1)
        if P.get(k) is None: P[k] = float(v)
        else: P[k] = type(P[k])(v) if not isinstance(P[k], (list, dict)) else eval(v)
    os.makedirs("out", exist_ok=True)

    parts = {"tri": tri(), "tile": tile(), "half": half(), "dart": dart(), "con_0": connector(0)}
    for a in P["angles"]:
        parts[f"con_{a}"] = connector(a)

    # individual files (labelled)
    for name, poly in parts.items():
        b = poly.bounds
        p = translate(poly, -b[0] + 2, -b[1] + 2)
        write_dxf([(p, 0, 0, None)], f"out/{name}.dxf")
        write_svg([(p, 0, 0, None)], f"out/{name}.svg", b[2] - b[0] + 4, b[3] - b[1] + 4)

    ft, fits = fit_test()
    b = ft.bounds
    labels = [(box(0,0,0.01,0.01), 6 + 12 * i, 4, f"{f:+.1f}") for i, f in enumerate(fits)]
    write_dxf([(ft, 0, 0, None)] + labels, "out/fit_test.dxf")
    write_svg([(ft, 0, 0, None)] + labels, "out/fit_test.svg", b[2] + 2, b[3] + 2)

    # full sheet
    items = []
    for name, n in P["counts"].items():
        if name in parts:
            items += [(name, parts[name])] * n
    items.sort(key=lambda it: -(it[1].bounds[3] - it[1].bounds[1]))
    sheets = layout(items)
    for i, placed in enumerate(sheets, 1):
        write_dxf(placed, f"out/sheet_{i}.dxf")
        write_svg(placed, f"out/sheet_{i}.svg", P["sheet_w"], P["sheet_h"])

    print(f"slot width drawn = {slot_w():.2f} mm  (physical ≈ {P['thickness']+P['fit']:.2f} mm)")
    print(f"{len(items)} parts on {len(sheets)} sheet(s) of {P['sheet_w']} x {P['sheet_h']}")
    print("wrote out/" + ", ".join(sorted(os.listdir("out"))))

if __name__ == "__main__":
    main()

Triangles because a surface made of squares can only be flat or bent one way. Triangles are the smallest piece that can tile a curved surface: change the angle between neighbouring triangles and the surface curves. A triangle also cannot change shape once its three edges are fixed, so an assembled triangle mesh is stiff without extra bracing.

Slots on the edges and a separate connector because two rigid acrylic sheets cannot hook into each other the way felt can. Vogler's tab-and-slot only works because felt bends. So the panels carry only slots, and a small connector bridges two of them. That makes every panel identical and every edge identical, so any panel fits anywhere, which is what reconfigurable needs. The connector also carries the angle: a straight one gives a flat surface, an angled one folds it.

Everything is a parameter because the kit was designed in cardboard numbers and cut in acrylic. Only thickness and fit changed; the geometry followed.

Rebuilding the panel in Fusion 360

To understand the logic I rebuilt the panel and connector in Fusion 360 by myself, fully parametric. I gave myself one rule for the whole build: never type a number into a dimension, only a parameter name or an expression.

Parameters

I started with the parameters (Modify → Change Parameters → User Parameters).

name unit expression note
thickness mm 3 acrylic (measure the real sheet)
kerf mm 0.15 laser cut width
fit mm −0.05 negative = tighter
slot_w mm thickness + fit - kerf drawn slot width, 2.80
W mm 40 triangle edge
n_slots No Units 2 must be unitless or Pattern's Quantity rejects it
slot_pitch mm W / (n_slots + 1) 13.333
slot_d mm 5 insertion depth
corner_r mm 1.5
con_gap mm 1.5 gap between joined panels
con_height mm 12 fin height
con_len mm 2 * slot_d + con_gap 11.5

Panel

The triangle is an inscribed polygon with three sides, centred on the origin, in a sketch on XY. I dimensioned one edge and typed W; it shows as fx: 40.00 once linked. One vertex sits on the Y axis so the base is horizontal and the centroid is the origin, which the circular pattern needs later.

Then one slot on the base: a two-point rectangle standing on the base line, with three dimensions and one constraint. The width is slot_w (2.80). The top edge to the base is slot_d (5.00), which fixes both depth and vertical position. The right edge to the Y axis is slot_pitch / 2 - slot_w / 2 (5.267); the dimension is edge-based, so half the width has to be subtracted, and with slot_pitch / 2 alone the pair comes out asymmetric. The bottom-left corner is coincident with the base line. Lines turn black when fully constrained; blue means they can still move.

The second slot on the same edge is a rectangular pattern of the slot's four lines, window-selected so the base line is not caught, along the base line, with distribution set to Spacing, quantity n_slots and distance slot_pitch. Two things went wrong first: n_slots stayed red until I changed its unit to No Units, and with Extent instead of Spacing the copy landed on the Y axis, because Extent means total length.

Sketch: triangle with one fully dimensioned slot
The triangle with one fully constrained slot. Every dimension is a parameter or an expression.
Rectangular Pattern dialog
The rectangular pattern that puts the second slot on the base edge.

The other two edges come from a circular pattern of the six slot lines about the centroid, full, quantity 3. The slots rotate 120° with the triangle, so they stay perpendicular to every edge. A rectangular pattern can only translate, which is why this step needs the circular one. Then extrude by thickness, selecting only the big triangular profile. My first attempt selected the six small slot profiles instead, which would have extruded six little bars.

Finally a fillet of corner_r on the three corners, and optionally 0.3–0.5 mm on the slot roots, where acrylic cracks.

Connector

The connector con_0 lives in the same file, because parameters are per file in Fusion; the document is in Part Design mode, so it is a second body rather than a component. It is a centre rectangle of con_len × con_height in a new sketch on XY, away from the panel. A slot at the right end is slot_d horizontal by slot_w vertical, with its top-right corner coincident with the body's right edge and its top edge (con_height - slot_w) / 2 from the body top, which centres it without the origin. The same at the left end, or a mirror. Then extrude the H-shaped profile by thickness as a New Body; Join would fuse it to the panel. The result is an H on its side: two 5 mm slots from the ends, a 1.5 mm neck (con_gap), and 4.6 mm rails top and bottom.

To check the assembly I moved the connector body with M: rotate 90° so it stands, rotate 90° again about one of its thickness edges so its length runs perpendicular to the panel edge, then drag until the slot bottoms meet. That took three wrong orientations. The test that finally worked: seen from the top, the connector must read as a 2.8 mm line sticking 6.5 mm past the edge.

Wrong profile selection
Wrong: the six slot profiles are selected in blue. The right pick is the one large face.
Finished panel body
The finished panel body after the fillet.
Connector inserted in a panel slot
The connector seated in a panel slot.

Parameters live per file, so panel and connector belong in the same file. Quantity fields need a unitless parameter. Coincident is point-to-curve; line-to-line is Collinear. Extrude selects profiles, so pick the big face, because the slots are separate profiles. And slot positions must be symmetric about the edge midpoint, because the neighbouring panel sees the edge reversed.

Kerf and fit

Characterizing the laser (group)

Before cutting anything of my own, we characterized the lab's xTool machines as a group: power and speed on cardboard and acrylic, then focus and kerf.

Cut test grid, xTool F1 Ultra, cardboard
Cut test on the xTool F1 Ultra. Power runs 50–100 % across the columns and speed 10–40 mm/s down the rows. Cardboard only cuts through in the bottom rows, at 15 mm/s or slower. Above that the line is scored, not cut, and at 100 % and 10 mm/s the edges char.
Score test grid, xTool P2, acrylic
Score test on the xTool P2 in orange acrylic: how visible a scored line is across 10–90 % power and 100–250 mm/s.
Focus test
The same circle cut at eight focus offsets. The crispest line marks the right height.
Kerf test
Circles drawn with 0.0–0.7 mm of outward compensation. Dropping each cut-out disc back into its hole, the one that just fits gives the kerf.
Name tags
Name tags.

The laser burns a groove of width kerf, centred on the drawn line, so a slot comes out kerf wider than drawn. To get a physical slot of thickness + fit, the file has to draw it kerf narrower, which is where slot_w = thickness + fit − kerf comes from. fit is the design intent, negative for a press fit and positive for a slip fit; kerf is the machine's property. Only the difference fit − kerf reaches the file, so in practice either one can be adjusted.

Test with acrylic

The first fit test was a strip with eight slots labelled −0.4 to +0.3 mm relative to the nominal 3.0 mm thickness. Every slot was loose, even −0.4. So the sheet was thinner than 3 mm, or the kerf wider than I had guessed, or both.

The second test shifted the range to −1.0 to −0.3, and −0.5 held. I cut panels and connectors at −0.5, and they would not go together at all. The strip only loads one joint, where a slot grips a piece of scrap. A real joint loads two at once, the panel gripping the connector and the connector gripping the panel, so it needs a looser number than the strip suggests.

For the third test, I cut three rows, each with two triangles and two connectors, all four parts cut at the same fit (−0.3, −0.2 and −0.1), with labels engraved so the parts could be matched after they dropped out of the sheet. The first attempt had no labels and the parts were impossible to tell apart. Panel slots at −0.2 and connector slots at −0.1 work. The panel and the connector come from the same sheet and still want different numbers, probably because the small connector heats and shrinks slightly. The generator now takes fit and con_fit separately.

Fit test strip
Fit test 1. All eight slots, down to −0.4 mm, were loose.
Fit test 3: labelled triangles and connectors
Fit test 3. Two triangles and two connectors per row, cut at the same fit, labels engraved. The −0.2 / −0.1 combination is the one that holds.

Fire

When I cut the acrylic at 80 % power and 20 mm/s, it caught fire. Two things probably came together: I had not removed the protective paper from the acrylic, and there was cardboard left on the bed from the mannequin test. The cardboard is most likely what burned. Next time I will clean the bed before running any job. And once something catches fire, stop immediately, with the emergency stop or pause, and put it out with the fire blanket.

Parts from the cut that caught fire
The parts from the cut that caught fire, paper still on. The connectors are charred at the edges.

The same file on a black sheet did not fit at all. Nominal 3 mm extruded acrylic ranges roughly 2.6–3.1 mm, and a 0.4 mm change is far larger than the 0.1 mm steps of the fit test. The rule is now: change sheet, measure its thickness with calipers, set thickness. fit and kerf stay. Regenerating takes seconds.

The small scale exposed a bug. Shrinking the skirt to 0.4 scale made the first-ring triangles pointy, with a 49° apex. With two slots per edge at one third and two thirds, the two slots near the apex crossed and cut the apex off as a loose fragment. The fix is one slot per edge for small triangles, which also halves the connector count, or bigger triangles for two slots. The generator now reports an error when slots intersect.

Test with plywood

The acrylic ran out, so I switched to 3 mm plywood. At 90 % power and 30 mm/s the beam did not go through; the parts tore when I pushed them out of the sheet and the thin connector rails broke. At 90 % and 20 mm/s the cut went through and the parts dropped out on their own. The lesson is not to pry: if a part needs to be pushed out, cut again more slowly rather than force it.

The acrylic numbers did not transfer either. Rows at fit −0.3, −0.2 and −0.1 were all too tight for the plywood, so I cut a second strip at 0.0, +0.1, +0.2 and +0.3. Wood is more forgiving than acrylic, because it compresses instead of cracking, so the useful range is wider. Two changes went into this second strip: the slot roots are rounded (0.4 mm) so cracks do not start at the inner corners, and the connector height went from 12 to 14 mm so the rails are thicker. The result is +0.3 for both the panel slots and the connector slots. Final numbers: thickness 3, kerf 0.25, fit +0.3. Only the difference fit − kerf reaches the file, so kerf stays at the guessed value and fit carries the correction.

Plywood parts cut at 90 % / 30 mm/s
90 % power, 30 mm/s: not through. Pressing the parts out broke the narrow sections.
Plywood fit test strips
The second fit test, 0.0 to +0.3, with rounded slot roots. Row 3 (+0.3) is the one that holds.

With the numbers confirmed, I cut the whole kit: 42 panels in six labelled shapes and the connectors.

Kit on the laser bed
The kit on the bed: panels labelled by ring and direction (1d, 1u, 2d …), connectors along the left edge.
Sorted parts
Sorted by label, connectors in a box.

Assembled, the first connectors were too weak in the middle. The designed neck, the material between the two slots, was 1.5 mm, but the laser burns 0.12 mm off each slot bottom, so it came out at 1.25 mm, and the part label was engraved right on top of it, thinning it further. Three fixes: the neck is now drawn kerf-wider so the physical neck equals the parameter, the label moved to the rail, and the neck went from 1.5 to 2.5 mm and then to 5 mm. The trade-off is a wider gap between panels, because the neck is the gap, so the assembled ring grows by about 14 × 3.5 mm per ring.

Three panels joined d–u–d curve as expected, and the first ring closes. But the neck was still too narrow: with two connectors on one edge, neither would seat at the centre.

Three panels joined
Three panels, d–u–d, joined with the angled connectors. The surface curves.
The first ring assembled
The first ring closed. The connectors sit off-centre because the neck is too narrow.

So I doubled the neck again, from 5 to 10 mm, and recut the connectors. Then it worked. The alignment is not perfect: the connector angles are not all the same, so the vertical edges between rings do not line up well yet. Then the time was up. I wanted to have fun with colours, so I sprayed the kit purple and gold.

Connectors with a 10 mm neck
The recut connectors with a 10 mm neck.
Two rings assembled
Two rings assembled with the 10 mm connectors. The panels seat, but the vertical edges drift.
The kit sprayed purple and gold
The kit sprayed purple and gold.

Next: joint and shape in one piece

The connector count is the weak point of the kit for a user. I want the joint and the shape combined. Vogler's module does this:

by having the positive and negative shapes on opposite sides, every triangle can fold up and connect to itself … having slots enables you to connect multiple positives to the same negative and create a dynamic web … weaving parts through the triangular part in the middle can replace darts

Her module works because felt bends; a rigid acrylic tab cannot pass through a slot. For acrylic this needs a hook shape that rotates in, on puzzle or chainmail logic, or a hybrid: acrylic panels with flexure connectors cut from polypropylene or PETG, which would also let the skirt move like cloth instead of holding a fixed polyhedral shape. Next I will try Vogler's shape in felt, and PP flexure connectors on the current panels.

Laser-cut nori

A side project, inspired by patterned nori with the asanoha (麻の葉) motif. I made the pattern file with Claude (asanoha_nori.svg), engraved asanoha and cut sakura into sheets of nori on the laser, and made onigiri with it.

Reference: asanoha patterned nori
The reference: laser-cut patterned nori for designer sushi. Source ↗
Engraved asanoha nori
Asanoha engraved into a sheet of nori.
Sakura cut-outs in nori
Sakura cut out of nori.
Onigiri wrapped in sakura nori
Onigiri wrapped in the sakura nori.

Nori turns out to be a good engraving test material: thin, uniform, and the pattern appears immediately as a colour change. The cut version needs low power and a single pass; it lifts and curls if the bed fan is strong. And the laser toasts it as it goes, so the nori comes out of the machine smelling good.

Files

Everything is under files/week01/ in the repo. modkit.py is the panel and connector generator with the fit test; skirt.py the flared skirt with per-ring panels and connectors at the needed angles; mannequin.py the waffle mannequin. The cut files are zipped: out_skirt.zip (panels, connectors and the 600 × 400 sheet), out_mannequin.zip (two sheets) and fit_tests.zip. week02_preview_en.html is the interactive preview, a single file with no build step. triangle.f3d is the Fusion version of panel and connector, parametric. asanoha_nori.svg is the nori pattern.

Use of AI

I used Claude (Fable 5.1) throughout the week in three roles. As a generator, it wrote modkit.py, skirt.py, mannequin.py and the Three.js preview from my descriptions and references, and we revised them together as tests came back: the apex bug, the separate panel and connector fit, the multi-sheet layout. I read the core functions and can explain the slot formula and the pattern logic; I did not write the geometry code. As a tutor, it walked me through the Fusion 360 panel and connector step by step and through each error (the unit of n_slots, Extent versus Spacing, Coincident versus Collinear, which profile to extrude); the build and the stumbles listed above are mine. As an editor, it drafted this page from my Obsidian notes, and I edited it. The inspiration list, the process log and the fit-test observations are my own.