Build a wizard hat and sculpt a face into it

Two ideas in one scene. First, a floppy purple hat assembled from primitives — a lathe-turned brim, a three-section cone that bends, a band, a buckle and extruded stars. Then the part that is not a texture: a smiley face sculpted into the cone by pushing a dense mesh's surface outward along its normals, as one undoable step. Open the editor, open the Shell tab, and paste each block in order — or open the finished hat, hat.json (step 7).

A purple wizard hat with a wide curled brim, a cone that bends at the tip, a dark band with a gold buckle, gold stars, and a raised smiley face sculpted into the front of the cone.
The finished hat: lathe brim, bending cone, band and buckle, stars — and a relief smiley sculpted into the front.
Pasting long blocks into the Shell: if Enter seems to do nothing after a long paste, press Esc first (it closes the autocomplete dropdown that was swallowing the keypress), then Enter. On a non-empty scene, run clearScene() first.

1 Materials and the brim

Three materials: the purple everywhere, a darker purple for the band and gold for the buckle and stars. A fourth, jointMat, is the same purple with a polygon offset — step 2 explains why. The brim is a LatheGeometry: one 2D profile (radius, height) spun around the y axis. The profile runs out along the underside, up the curled rim, and back along the top, so the brim has thickness and an upturned edge from a single list of points:

const purple = new MeshStandardMaterial({ color: 0x6a2bb8, roughness: 0.65, side: THREE.DoubleSide });
const bandMat = new MeshStandardMaterial({ color: 0x2e1260, roughness: 0.5 });
const gold = new MeshStandardMaterial({ color: 0xffd54a, roughness: 0.3, metalness: 0.7, emissive: 0x3a2a00 });
// Same purple, pushed back in depth so it never z-fights the cone walls it overlaps
const jointMat = new MeshStandardMaterial({ color: 0x6a2bb8, roughness: 0.65, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 8, polygonOffsetUnits: 8 });

const hat = new Group();
hat.name = 'Wizard Hat';

// Brim: a lathe profile that dips, then curls up at the rim (out along the underside, back along the top)
const brimProfile = [[0.85,0.02],[1.2,0.0],[1.7,-0.02],[2.05,0.04],[2.2,0.14],[2.2,0.18],[2.15,0.19],[2.0,0.1],[1.7,0.05],[1.2,0.06],[0.85,0.08]].map(p => new THREE.Vector2(p[0], p[1]));
const brim = new Mesh(new LatheGeometry(brimProfile, 64), purple);
brim.name = 'Brim';
hat.add(brim);

2 The cone: three sections that bend

A single cone cannot flop over, so build it in three tapering sections. Each section is a Group parented to the top of the previous one and rotated a little more (-0.25, then -0.5 radians); the cylinder inside is offset up by half its height so the group's origin is the hinge. Because every section inherits its parent's rotation, the bend accumulates into a curl. A sphere sits at each hinge, with the same radius as the section below, to fill the wedge that opens on the outside of the bend:

const H1 = 1.3, H2 = 1.2, H3 = 1.1;
const seg1 = new Group(); seg1.name = 'Crown Section 1'; seg1.position.y = 0.07; hat.add(seg1);
const m1 = new Mesh(new CylinderGeometry(0.72, 0.95, H1, 64), purple); m1.name = 'Crown 1'; m1.position.y = H1 / 2; seg1.add(m1);

const seg2 = new Group(); seg2.name = 'Crown Section 2'; seg2.position.y = H1; seg2.rotation.z = -0.25; seg1.add(seg2);
const j1 = new Mesh(new SphereGeometry(0.72, 64, 32), jointMat); j1.name = 'Joint 1'; seg2.add(j1);
const m2 = new Mesh(new CylinderGeometry(0.42, 0.72, H2, 64), purple); m2.name = 'Crown 2'; m2.position.y = H2 / 2; seg2.add(m2);

const seg3 = new Group(); seg3.name = 'Crown Section 3'; seg3.position.y = H2; seg3.rotation.z = -0.5; seg2.add(seg3);
const j2 = new Mesh(new SphereGeometry(0.42, 64, 32), jointMat); j2.name = 'Joint 2'; seg3.add(j2);
const m3 = new Mesh(new CylinderGeometry(0.05, 0.42, H3, 64), purple); m3.name = 'Crown 3'; m3.position.y = H3 / 2; seg3.add(m3);
const tip = new Mesh(new SphereGeometry(0.07, 16, 12), purple); tip.name = 'Tip'; tip.position.y = H3; seg3.add(tip);
A bug worth checking for. The hinge spheres are almost, but not exactly, inside the cone walls, so with the default material they z-fight with them and a sawtooth edge appears at every bend (it gets worse the more finely the cone is tessellated). Shrinking the spheres does not cure it. Giving the joints their own material with polygonOffset pushes them slightly back in depth, so the cone walls always win the tie and the sphere only shows where the bend genuinely opens a gap. Matching the segment counts (64 on the cones and spheres) shrinks what is left.

3 Band, buckle and stars

The band is a cylinder whose two radii are read off the cone's own radius function r1(t) (radius at height t), so it hugs the slope instead of floating or sinking. The buckle is a box with a smaller dark box as its hole. The stars are a 10-point Shape (alternating outer and inner radius) run through ExtrudeGeometry; each is placed on the cone at an angle and then tipped by the cone's slope so it lies flat against it:

const r1 = t => 0.95 - 0.23 * (t / H1); // crown 1 radius at height t
const band = new Mesh(new CylinderGeometry(r1(0.36) + 0.02, r1(0.14) + 0.02, 0.22, 40), bandMat);
band.name = 'Hat Band'; band.position.y = 0.25;
seg1.add(band);
const buckle = new Mesh(new BoxGeometry(0.3, 0.28, 0.06), gold);
buckle.name = 'Buckle'; buckle.position.set(0, 0.25, r1(0.25) + 0.05); buckle.rotation.x = -0.1;
seg1.add(buckle);
const hole = new Mesh(new BoxGeometry(0.16, 0.14, 0.07), bandMat);
hole.name = 'Buckle Hole'; hole.position.set(0, 0.25, r1(0.25) + 0.055); hole.rotation.x = -0.1;
seg1.add(hole);

// Gold stars, tilted to hug the cone's slope
const starShape = new Shape();
for (let i = 0; i < 10; i++) {
  const a = Math.PI / 2 + i * Math.PI / 5, r = i % 2 ? 0.07 : 0.17;
  i ? starShape.lineTo(Math.cos(a) * r, Math.sin(a) * r) : starShape.moveTo(Math.cos(a) * r, Math.sin(a) * r);
}
starShape.closePath();
const starGeo = new ExtrudeGeometry(starShape, { depth: 0.02, bevelEnabled: false });
const slope = Math.atan(0.23 / H1);
[-0.9, 0.9, 2.4, 3.9].forEach((ang, i) => {
  const star = new Mesh(starGeo, gold);
  star.name = 'Star ' + (i + 1);
  const t = 0.85, r = r1(t) + 0.005;
  star.position.set(Math.sin(ang) * r, t, Math.cos(ang) * r);
  star.rotation.order = 'YXZ';
  star.rotation.y = ang;
  star.rotation.x = -slope;
  seg1.add(star);
});

editor.execute(new AddObjectCommand(editor, hat));
tagClass(hat, 'hat');

4 Add it to the scene

One AddObjectCommand adds the whole hat, so it is one undo step. Tag the group with a class, add a cool ambient light, a white key light and a violet rim light from behind, set a dark background, and aim the camera:

editor.execute(new AddObjectCommand(editor, new AmbientLight(0xcfc4ff, 0.7)));
const key = new DirectionalLight(0xffffff, 1.6);
key.position.set(5, 8, 6);
editor.execute(new AddObjectCommand(editor, key));
const rim = new DirectionalLight(0x9a7bff, 0.8);
rim.position.set(-6, 4, -5);
editor.execute(new AddObjectCommand(editor, rim));
setBackground('#1b1530');

const cam = editor.camera;
cam.position.set(3.2, 2.6, 5.6);
cam.fov = 45;
cam.lookAt(0.4, 1.6, 0);
cam.updateProjectionMatrix();
editor.controls.center.set(0.4, 1.6, 0);
editor.signals.cameraChanged.dispatch(cam);
editor.signals.objectChanged.dispatch(cam);

5 Sculpt the face

The smiley is not painted on and not a separate decal object. It is the cone's own surface, pushed outward. Only the straight lower section (Crown 1) is sculpted, because the face lives on its front. Paste this whole block; the Shell does not allow top-level await, so everything runs inside an async function:

(async () => {
  const { SetGeometryCommand } = await import('/editor/js/commands/SetGeometryCommand.js');
  const crown = editor.scene.getObjectByName('Crown 1');

  // A dense replacement for the crown's straight section. thetaStart = PI moves the
  // seam to the back of the hat, away from the face (the front is +z).
  const H = 1.3, RT = 0.72, RB = 0.95, RAD = 320, HEI = 140;
  const g = new CylinderGeometry(RT, RB, H, RAD, HEI, true, Math.PI, Math.PI * 2);
  const rAt = t => RB - (RB - RT) * (t / H); // crown radius at height t

  // Soft dome falloff: 1 at the centre, 0 at distance 1
  const bump = d => { const w = Math.min(1, Math.max(0, 1 - d)); return w * w * (3 - 2 * w); };

  // The face, in surface coordinates: u = arc length around the hat, v = height above the face centre
  const TC = 0.8;
  const MR = 0.21, MY = 0.02, P0 = -2.55, P1 = -0.59; // smile = the lower arc of a circle
  const mouthDist = (u, v) => {
    const dy = v - MY, rho = Math.hypot(u, dy), ph = Math.atan2(dy, u);
    if (ph >= P0 && ph <= P1) return Math.abs(rho - MR);
    const e = p => Math.hypot(u - MR * Math.cos(p), dy - MR * Math.sin(p));
    return Math.min(e(P0), e(P1)); // past the ends of the arc, measure to the nearest end
  };
  const relief = (u, v) => {
    const base = 0.02 * bump((Math.hypot(u, v) - 0.3) / 0.06); // raised disc, flat top, soft edge
    const eyes = [-0.12, 0.12].reduce((s, ex) => s + bump(Math.hypot((u - ex) / 0.05, (v - 0.1) / 0.075)), 0) * 0.035;
    const mouth = bump(mouthDist(u, v) / 0.04) * 0.03;
    return base + eyes + mouth;
  };

  // Push each vertex out along its own normal by the relief height
  const pos = g.attributes.position, nor = g.attributes.normal;
  for (let i = 0; i < pos.count; i++) {
    const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
    const ang = Math.atan2(x, z);
    if (Math.abs(ang) > 0.9) continue; // only the front of the hat can be touched
    const t = y + H / 2;
    const h = relief(ang * rAt(t), t - TC);
    if (h > 0) pos.setXYZ(i, x + nor.getX(i) * h, y + nor.getY(i) * h, z + nor.getZ(i) * h);
  }
  g.computeVertexNormals();

  // computeVertexNormals leaves a lighting seam where the first and last columns meet; average them
  for (let r = 0; r <= HEI; r++) {
    const a = r * (RAD + 1), b = a + RAD;
    const nx = nor.getX(a) + nor.getX(b), ny = nor.getY(a) + nor.getY(b), nz = nor.getZ(a) + nor.getZ(b);
    const len = Math.hypot(nx, ny, nz) || 1;
    nor.setXYZ(a, nx / len, ny / len, nz / len);
    nor.setXYZ(b, nx / len, ny / len, nz / len);
  }
  nor.needsUpdate = true;

  // A CylinderGeometry is saved by its parameters (radius, segments...), which would throw the
  // sculpted vertices away on Save. Copy them into a plain BufferGeometry so the file keeps them.
  const baked = new THREE.BufferGeometry();
  baked.setAttribute('position', pos);
  baked.setAttribute('normal', nor);
  baked.setAttribute('uv', g.attributes.uv);
  baked.setIndex(g.index);

  editor.execute(new SetGeometryCommand(editor, crown, baked)); // one undo step
  editor.deselect();
  return 'sculpted ' + pos.count + ' vertices';
})()
Close-up of the hat's cone: two raised oval eyes and a curved smile on a slightly raised disc, sculpted into the purple surface, between a gold buckle below and gold stars beside.
Close-up: two raised eyes and a smile on a slightly raised disc, all part of the cone's geometry.

Why each piece is there:

6 Check it

Confirm the geometry really changed, and that the sculpt is reversible rather than baked into the scene:

const crown = () => editor.scene.getObjectByName('Crown 1').geometry.attributes.position.count;
editor.scene.getObjectByName('Crown 1').geometry.type; // 'BufferGeometry'
crown();            // 45261
undo(); crown();    // 388 (the original 64-sided cone is back)
redo(); crown();    // 45261
$S('.hat').count(); // 1
Make the face your own. Everything lives in relief(u, v). Change TC to move the face up or down the cone, the eye centres ±0.12 and the 0.035 eye height to change the expression, or swap the smile's arc range P0 / P1 and radius MR for a frown or a surprised mouth. Re-running the block replaces the old geometry with a freshly sculpted one.

7 Open the finished hat

The finished scene is public in tejaswigowda/test1: hat.json opens straight in the editor with the brim, the bending cone, the sculpted face, the band, buckle and stars, the three lights and the dark background, all named and tagged as built above.

What is in the file. hat.json includes the sculpted face, because step 5 bakes the crown into plain vertex data before it is saved. Saved from the editor's Git panel, big geometry buffers go out as binary assets next to a small scene file (here about 22 KB of JSON plus a 2.5 MB crown buffer); File → Save writes everything inline, about 8.5 MB. Either way the face survives a save and reload, which a CylinderGeometry crown would not.

Credits and licences

People

Assets

None used.

Tools

None used.