// Schwarz P-Surface — Triply Periodic Minimal Surface (Hermann Schwarz, 1865)
//
// Field equation: cos(k·x) + cos(k·y) + cos(k·z) = 0
//
// The simplest TPMS: cubic symmetry that bisects space into two interpenetrating
// but unconnected channels. Unlike the Gyroid it has straight lines and planes
// of symmetry ("primitive" = no chiral handedness).
// Meshed via Naive Surface Nets for smooth, watertight output.
import * as THREE from 'three';
export const WARMUP = { framesBeforeReady: 60 };
export const PARAMS = {
resolution: { value: 50, min: 20, max: 80, step: 2, label: "Grid Resolution", folder: "Structure", rebuildOnChange: true },
cellSize: { value: 6.0, min: 1.5, max: 12, step: 0.1, label: "Cell Size", folder: "Structure" },
thickness: { value: 0.0, min: -0.4, max: 0.4, step: 0.01, label: "Iso Offset", folder: "Structure" },
hue: { value: 30, min: 0, max: 360, step: 1, label: "Hue", folder: "Appearance" },
rotateSpeed: { value: 0.15, min: 0, max: 1, step: 0.01, label: "Rotate Speed", folder: "Behavior" },
};
// --- Naive Surface Nets extractor ---
function naiveSurfaceNets(field, dims, bounds, isovalue) {
const [nx, ny, nz] = dims;
const idx = (x, y, z) => x + nx * (y + ny * z);
const cellVertex = new Int32Array(nx * ny * nz).fill(-1);
const cornerOffsets = [
[0,0,0],[1,0,0],[1,1,0],[0,1,0],
[0,0,1],[1,0,1],[1,1,1],[0,1,1],
];
const edgeList = [
[0,1],[1,2],[2,3],[3,0],
[4,5],[5,6],[6,7],[7,4],
[0,4],[1,5],[2,6],[3,7],
];
const positions = [];
const indices = [];
// Pass A — one vertex per cell that straddles the isovalue
for (let z = 0; z < nz - 1; z++)
for (let y = 0; y < ny - 1; y++)
for (let x = 0; x < nx - 1; x++) {
const c = [
field[idx(x, y, z )], field[idx(x+1, y, z )],
field[idx(x+1, y+1, z )], field[idx(x, y+1, z )],
field[idx(x, y, z+1)], field[idx(x+1, y, z+1)],
field[idx(x+1, y+1, z+1)], field[idx(x, y+1, z+1)],
];
let sx = 0, sy = 0, sz = 0, n = 0;
for (const [a, b] of edgeList) {
const va = c[a], vb = c[b];
if ((va < isovalue) !== (vb < isovalue)) {
const t = (isovalue - va) / (vb - va);
const oa = cornerOffsets[a], ob = cornerOffsets[b];
sx += oa[0] + t * (ob[0] - oa[0]);
sy += oa[1] + t * (ob[1] - oa[1]);
sz += oa[2] + t * (ob[2] - oa[2]);
n++;
}
}
if (n > 0) {
const cx = (x + sx / n) / (nx - 1);
const cy = (y + sy / n) / (ny - 1);
const cz = (z + sz / n) / (nz - 1);
cellVertex[idx(x, y, z)] = positions.length / 3;
positions.push(
bounds.min[0] + cx * (bounds.max[0] - bounds.min[0]),
bounds.min[1] + cy * (bounds.max[1] - bounds.min[1]),
bounds.min[2] + cz * (bounds.max[2] - bounds.min[2]),
);
}
}
// Pass B — quads across edges with sign changes
for (let z = 1; z < nz - 1; z++)
for (let y = 1; y < ny - 1; y++)
for (let x = 1; x < nx - 1; x++) {
const v0 = field[idx(x, y, z)];
const vx = field[idx(x+1, y, z)];
if ((v0 < isovalue) !== (vx < isovalue)) {
const a = cellVertex[idx(x, y-1, z-1)], b = cellVertex[idx(x, y, z-1)];
const cc = cellVertex[idx(x, y, z )], d = cellVertex[idx(x, y-1, z )];
if (a >= 0 && b >= 0 && cc >= 0 && d >= 0) {
if (v0 < isovalue) indices.push(a, b, cc, a, cc, d);
else indices.push(a, cc, b, a, d, cc);
}
}
const vy = field[idx(x, y+1, z)];
if ((v0 < isovalue) !== (vy < isovalue)) {
const a = cellVertex[idx(x-1, y, z-1)], b = cellVertex[idx(x, y, z-1)];
const cc = cellVertex[idx(x, y, z )], d = cellVertex[idx(x-1, y, z )];
if (a >= 0 && b >= 0 && cc >= 0 && d >= 0) {
if (v0 < isovalue) indices.push(a, d, cc, a, cc, b);
else indices.push(a, b, cc, a, cc, d);
}
}
const vz = field[idx(x, y, z+1)];
if ((v0 < isovalue) !== (vz < isovalue)) {
const a = cellVertex[idx(x-1, y-1, z)], b = cellVertex[idx(x, y-1, z)];
const cc = cellVertex[idx(x, y, z)], d = cellVertex[idx(x-1, y, z)];
if (a >= 0 && b >= 0 && cc >= 0 && d >= 0) {
if (v0 < isovalue) indices.push(a, b, cc, a, cc, d);
else indices.push(a, cc, b, a, d, cc);
}
}
}
return {
positions: new Float32Array(positions),
indices: new Uint32Array(indices),
};
}
function buildSchwarzMesh(THREE, params) {
const res = params.resolution | 0;
const scale = params.cellSize;
const isovalue = params.thickness;
const dims = [res, res, res];
const half = Math.PI * scale / 2;
const bounds = { min: [-half, -half, -half], max: [half, half, half] };
const field = new Float32Array(res * res * res);
const dx = (bounds.max[0] - bounds.min[0]) / (res - 1);
const dy = (bounds.max[1] - bounds.min[1]) / (res - 1);
const dz = (bounds.max[2] - bounds.min[2]) / (res - 1);
let i = 0;
for (let iz = 0; iz < res; iz++) {
const wz = bounds.min[2] + iz * dz;
const cosZ = Math.cos(wz);
for (let iy = 0; iy < res; iy++) {
const wy = bounds.min[1] + iy * dy;
const cosY = Math.cos(wy);
for (let ix = 0; ix < res; ix++) {
const wx = bounds.min[0] + ix * dx;
field[i++] = Math.cos(wx) + cosY + cosZ;
}
}
}
const { positions, indices } = naiveSurfaceNets(field, dims, bounds, isovalue);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
geo.computeVertexNormals();
return geo;
}
export function sceneSetup(THREE, scene, camera, renderer, params, seed) {
scene.background = new THREE.Color(0x07080a);
camera.position.set(0, 0, 12);
camera.lookAt(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
const dirA = new THREE.DirectionalLight(0xffffff, 1.4);
dirA.position.set(5, 5, 5);
scene.add(dirA);
const dirB = new THREE.DirectionalLight(0xffaa55, 0.4);
dirB.position.set(-5, -3, -5);
scene.add(dirB);
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(params.hue / 360, 0.7, 0.55),
roughness: 0.3,
metalness: 0.25,
side: THREE.DoubleSide,
});
const geo = buildSchwarzMesh(THREE, params);
const mesh = new THREE.Mesh(geo, mat);
scene.add(mesh);
const prev = { cellSize: params.cellSize, thickness: params.thickness };
return { mesh, mat, geo, prev };
}
export function sceneAnimate(THREE, scene, camera, state, params, time, delta) {
const { mesh, mat, prev } = state;
mat.color.setHSL(params.hue / 360, 0.7, 0.55);
if (params.cellSize !== prev.cellSize || params.thickness !== prev.thickness) {
prev.cellSize = params.cellSize;
prev.thickness = params.thickness;
const oldGeo = state.geo;
const newGeo = buildSchwarzMesh(THREE, params);
mesh.geometry = newGeo;
state.geo = newGeo;
oldGeo.dispose();
}
// Slow rotation reveals the cubic 6-fold symmetry
mesh.rotation.y = time * params.rotateSpeed;
mesh.rotation.x = time * params.rotateSpeed * 0.35;
}