From 2f8a7f873f4c7388a7c4b4ef927aab184dd5dbc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Nov 2025 04:15:38 +0000 Subject: [PATCH] feat: Add primitive shapes and deformation controls Co-authored-by: steinbergisaac --- README.md | 2 +- v0.5/index.html | 1 + v0.5/src/geometry/primitives.js | 222 ++++++++++++++++++++++++++++++++ v0.5/src/main.js | 5 + v0.5/src/params.js | 29 ++++- v0.5/src/rendering/mesh.js | 83 ++++++++++++ v0.5/src/ui/dynamicControls.js | 79 ++++++++++++ 7 files changed, 417 insertions(+), 4 deletions(-) create mode 100644 v0.5/src/geometry/primitives.js diff --git a/README.md b/README.md index e2677c8..bb912ef 100644 --- a/README.md +++ b/README.md @@ -38,5 +38,5 @@ python -m http.server 8000 ## Project Structure -- `v0.5/` - Current version (plain HTML/JS, no build step required) +- `v0.5/` - Current version (plain HTML/JS, no build step required). Includes the new geometry mode toggle so you can switch between parametric tubes (spirals, helices, etc.) and editable primitive shapes such as spheres, cubes, pentagonal/decagonal prisms, discs, and dodecahedrons. Primitive shapes support real-time twist, taper, bend, noise, and inflate deformations directly in the UI. - `v0.1/`, `v0.2/` - Previous versions with build systems diff --git a/v0.5/index.html b/v0.5/index.html index 80232b8..8386e50 100644 --- a/v0.5/index.html +++ b/v0.5/index.html @@ -105,6 +105,7 @@

Config

+ diff --git a/v0.5/src/geometry/primitives.js b/v0.5/src/geometry/primitives.js new file mode 100644 index 0000000..6bc6f46 --- /dev/null +++ b/v0.5/src/geometry/primitives.js @@ -0,0 +1,222 @@ +// Primitive geometry generators and deformation helpers + +const primitiveGenerators = { + 'circle-disc': { + name: 'Circle / Disc', + buildGeometry: (params) => { + const radius = params.primitiveRadius || 1.5; + const segments = Math.max(3, Math.floor(params.primitiveSegments || 48)); + return new THREE.CircleGeometry(radius, segments); + }, + params: { + primitiveRadius: { type: 'range', min: 0.2, max: 4, step: 0.01, label: 'Radius' }, + primitiveSegments: { type: 'range', min: 8, max: 160, step: 1, label: 'Segments' } + } + }, + 'sphere': { + name: 'Sphere', + buildGeometry: (params) => { + const radius = params.primitiveRadius || 1.8; + const widthSegs = Math.max(8, Math.floor(params.primitiveWidthSegments || 32)); + const heightSegs = Math.max(6, Math.floor(params.primitiveHeightSegments || 24)); + return new THREE.SphereGeometry(radius, widthSegs, heightSegs); + }, + params: { + primitiveRadius: { type: 'range', min: 0.2, max: 4, step: 0.01, label: 'Radius' }, + primitiveWidthSegments: { type: 'range', min: 8, max: 160, step: 1, label: 'Horizontal Segments' }, + primitiveHeightSegments: { type: 'range', min: 6, max: 160, step: 1, label: 'Vertical Segments' } + } + }, + 'cube': { + name: 'Cube', + buildGeometry: (params) => { + const size = params.primitiveBoxSize || 2; + const subdivisions = Math.max(1, Math.floor(params.primitiveBoxSubdivisions || 1)); + return new THREE.BoxGeometry(size, size, size, subdivisions, subdivisions, subdivisions); + }, + params: { + primitiveBoxSize: { type: 'range', min: 0.2, max: 4, step: 0.01, label: 'Size' }, + primitiveBoxSubdivisions: { type: 'range', min: 1, max: 12, step: 1, label: 'Subdivisions' } + } + }, + 'pentagonal-prism': { + name: 'Pentagonal Prism', + buildGeometry: (params) => { + const radius = params.primitiveRadius || 1.6; + const height = params.primitiveHeight || 2.5; + const stacks = Math.max(1, Math.floor(params.primitiveStackSegments || 1)); + return new THREE.CylinderGeometry(radius, radius, height, 5, stacks, false); + }, + params: { + primitiveRadius: { type: 'range', min: 0.2, max: 4, step: 0.01, label: 'Radius' }, + primitiveHeight: { type: 'range', min: 0.2, max: 5, step: 0.01, label: 'Height' }, + primitiveStackSegments: { type: 'range', min: 1, max: 20, step: 1, label: 'Height Segments' } + } + }, + 'decagonal-prism': { + name: 'Decagonal Prism (Decahedron)', + buildGeometry: (params) => { + const radiusTop = params.primitiveTopRadius || params.primitiveRadius || 1.4; + const radiusBottom = params.primitiveBottomRadius || params.primitiveRadius || 1.4; + const height = params.primitiveHeight || 2.2; + const stacks = Math.max(1, Math.floor(params.primitiveStackSegments || 1)); + return new THREE.CylinderGeometry(radiusTop, radiusBottom, height, 10, stacks, false); + }, + params: { + primitiveTopRadius: { type: 'range', min: 0.2, max: 4, step: 0.01, label: 'Top Radius' }, + primitiveBottomRadius: { type: 'range', min: 0.2, max: 4, step: 0.01, label: 'Bottom Radius' }, + primitiveHeight: { type: 'range', min: 0.2, max: 5, step: 0.01, label: 'Height' }, + primitiveStackSegments: { type: 'range', min: 1, max: 20, step: 1, label: 'Height Segments' } + } + }, + 'dodecahedron': { + name: 'Dodecahedron', + buildGeometry: (params) => { + const radius = params.primitiveRadius || 1.8; + const detail = Math.max(0, Math.floor(params.primitiveDetail || 0)); + return new THREE.DodecahedronGeometry(radius, detail); + }, + params: { + primitiveRadius: { type: 'range', min: 0.2, max: 4, step: 0.01, label: 'Radius' }, + primitiveDetail: { type: 'range', min: 0, max: 4, step: 1, label: 'Detail' } + } + } +}; + +const primitiveDeformers = { + primitiveTwistAmount: { type: 'range', min: -4, max: 4, step: 0.01, label: 'Twist (turns)' }, + primitiveTaperAmount: { type: 'range', min: -1, max: 1, step: 0.01, label: 'Taper' }, + primitiveBendAmount: { type: 'range', min: -Math.PI, max: Math.PI, step: 0.01, label: 'Bend (radians)' }, + primitiveNoiseAmplitude: { type: 'range', min: 0, max: 1, step: 0.01, label: 'Noise Amplitude' }, + primitiveNoiseFrequency: { type: 'range', min: 0.5, max: 8, step: 0.1, label: 'Noise Frequency' }, + primitiveInflate: { type: 'range', min: -0.8, max: 1.5, step: 0.01, label: 'Inflate' } +}; + +function getPrimitiveGenerator(type) { + return primitiveGenerators[type] || primitiveGenerators['sphere']; +} + +function needsPrimitiveDeformation(params) { + return ( + (params.primitiveTwistAmount && Math.abs(params.primitiveTwistAmount) > 0.0001) || + (params.primitiveTaperAmount && Math.abs(params.primitiveTaperAmount) > 0.0001) || + (params.primitiveBendAmount && Math.abs(params.primitiveBendAmount) > 0.0001) || + (params.primitiveNoiseAmplitude && Math.abs(params.primitiveNoiseAmplitude) > 0.0001) || + (params.primitiveInflate && Math.abs(params.primitiveInflate) > 0.0001) + ); +} + +function pseudoNoise3(x, y, z, frequency) { + const f = frequency; + return Math.sin(f * x + 0.53) * Math.cos(f * y - 1.21) * Math.sin(f * z + 2.17); +} + +function applyPrimitiveDeformations(geometry, params) { + if (!geometry || !geometry.attributes || !geometry.attributes.position || !needsPrimitiveDeformation(params)) { + return; + } + + geometry.computeBoundingBox(); + const bbox = geometry.boundingBox || new THREE.Box3( + new THREE.Vector3(-1, -1, -1), + new THREE.Vector3(1, 1, 1) + ); + const center = new THREE.Vector3(); + bbox.getCenter(center); + const size = new THREE.Vector3(); + bbox.getSize(size); + const halfSize = size.clone().multiplyScalar(0.5); + + const positions = geometry.attributes.position; + const vertex = new THREE.Vector3(); + const radial = new THREE.Vector3(); + + const twistTurns = params.primitiveTwistAmount || 0; + const twistRadians = twistTurns * Math.PI * 2; + const taperAmount = params.primitiveTaperAmount || 0; + const bendAmount = params.primitiveBendAmount || 0; + const noiseAmplitude = params.primitiveNoiseAmplitude || 0; + const noiseFrequency = params.primitiveNoiseFrequency || 0; + const inflateAmount = params.primitiveInflate || 0; + + const halfY = halfSize.y || 1; + const halfX = halfSize.x || 1; + + for (let i = 0; i < positions.count; i++) { + vertex.set(positions.getX(i), positions.getY(i), positions.getZ(i)); + vertex.sub(center); + + if (inflateAmount !== 0) { + vertex.multiplyScalar(1 + inflateAmount); + } + + if (taperAmount !== 0) { + const normalizedY = halfY === 0 ? 0 : ((vertex.y / halfY) + 1) * 0.5; // 0..1 + const taperScale = 1 + taperAmount * (normalizedY - 0.5); + vertex.x *= taperScale; + vertex.z *= taperScale; + } + + if (twistRadians !== 0) { + const normalizedY = halfY === 0 ? 0.5 : ((vertex.y / halfY) + 1) * 0.5; + const angle = twistRadians * (normalizedY - 0.5); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x = vertex.x * cos - vertex.z * sin; + const z = vertex.x * sin + vertex.z * cos; + vertex.x = x; + vertex.z = z; + } + + if (bendAmount !== 0) { + const normalizedX = halfX === 0 ? 0.5 : ((vertex.x / halfX) + 1) * 0.5; + const angle = bendAmount * (normalizedX - 0.5); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const y = vertex.y * cos - vertex.z * sin; + const z = vertex.y * sin + vertex.z * cos; + vertex.y = y; + vertex.z = z; + } + + if (noiseAmplitude !== 0 && noiseFrequency !== 0) { + const noise = pseudoNoise3(vertex.x, vertex.y, vertex.z, noiseFrequency) * noiseAmplitude; + radial.copy(vertex); + const len = radial.length(); + if (len > 0.0001) { + radial.multiplyScalar(1 / len); + } else { + radial.set(0, 1, 0); + } + vertex.addScaledVector(radial, noise); + } + + vertex.add(center); + positions.setXYZ(i, vertex.x, vertex.y, vertex.z); + } + + positions.needsUpdate = true; + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + geometry.computeBoundingBox(); +} + +function buildPrimitiveGeometry(params) { + const generator = getPrimitiveGenerator(params.primitiveType || 'sphere'); + if (!generator || typeof generator.buildGeometry !== 'function') { + return new THREE.BufferGeometry(); + } + + let geometry = generator.buildGeometry(params); + if (!geometry) { + geometry = new THREE.BufferGeometry(); + } + + if (geometry && !geometry.isBufferGeometry && geometry.toBufferGeometry) { + geometry = geometry.toBufferGeometry(); + } + + applyPrimitiveDeformations(geometry, params); + return geometry; +} + diff --git a/v0.5/src/main.js b/v0.5/src/main.js index 2861694..eee88d9 100644 --- a/v0.5/src/main.js +++ b/v0.5/src/main.js @@ -7,6 +7,11 @@ const renderer = createRenderer(canvas); // Initialize with default parameters let currentParams = { ...defaultParams }; +// Ensure geometry mode defaults exist +if (!currentParams.geometryMode) { + currentParams.geometryMode = 'tube'; +} + // Ensure pathType and crossSectionType are set if (!currentParams.pathType) { currentParams.pathType = 'spiral'; diff --git a/v0.5/src/params.js b/v0.5/src/params.js index 667bc22..267ddcf 100644 --- a/v0.5/src/params.js +++ b/v0.5/src/params.js @@ -1,7 +1,9 @@ const defaultParams = { - // Base geometry type - pathType: 'spiral', // Path generator type - crossSectionType: 'circle', // Cross-section generator type + // Base geometry setup + geometryMode: 'tube', // 'tube' or 'primitive' + pathType: 'spiral', // Path generator type (tube mode) + crossSectionType: 'circle', // Cross-section generator type (tube mode) + primitiveType: 'sphere', // Primitive generator type (primitive mode) // Initialize default path and cross-section types if not set // (will be set by dynamic controls) @@ -60,6 +62,27 @@ const defaultParams = { noiseStrength: 0.3, // Noise strength (for deformed-sphere-noise) noiseScale: 3.0, // Noise scale (for deformed-sphere-noise) + // Primitive parameters (dynamically loaded based on primitiveType) + primitiveRadius: 1.8, // Shared radius default + primitiveSegments: 64, // Disc segments + primitiveWidthSegments: 32, // Sphere width segments + primitiveHeightSegments: 24, // Sphere height segments + primitiveBoxSize: 2.0, // Cube size + primitiveBoxSubdivisions: 1, // Cube subdivisions + primitiveHeight: 2.5, // Prism height + primitiveStackSegments: 1, // Prism stack segments + primitiveTopRadius: 1.4, // Decagonal top radius + primitiveBottomRadius: 1.4, // Decagonal bottom radius + primitiveDetail: 0, // Dodecahedron detail level + + // Primitive deformation controls + primitiveTwistAmount: 0.0, // Twist in turns + primitiveTaperAmount: 0.0, // Linear taper + primitiveBendAmount: 0.0, // Bend angle in radians + primitiveNoiseAmplitude: 0.0, // Noise displacement + primitiveNoiseFrequency: 2.5, // Noise frequency + primitiveInflate: 0.0, // Inflate/deflate amount + // Modifiers decayMode: 'Exponential', // 'None', 'Exponential', or 'Linear' k: 0.18, // Decay constant (for exponential) diff --git a/v0.5/src/rendering/mesh.js b/v0.5/src/rendering/mesh.js index 55d573e..5b08f69 100644 --- a/v0.5/src/rendering/mesh.js +++ b/v0.5/src/rendering/mesh.js @@ -160,10 +160,93 @@ function buildWireframeLines(params) { // Outline creation is now in outline.js - using createOutlineLines from there +function buildPrimitiveWireframeLines(geometry, params) { + const edgesGeometry = new THREE.EdgesGeometry(geometry); + const material = new THREE.LineBasicMaterial({ + color: new THREE.Color(params.outerColor), + linewidth: params.lineWidth, + transparent: false + }); + return new THREE.LineSegments(edgesGeometry, material); +} + +function updatePrimitiveMesh(meshGroup, params, camera) { + const geometry = buildPrimitiveGeometry(params); + if (!geometry) return; + + if (params.renderStyle === 'Solid') { + const material = new THREE.MeshStandardMaterial({ + color: params.outerColor, + side: THREE.DoubleSide, + metalness: 0.35, + roughness: 0.55 + }); + const mesh = new THREE.Mesh(geometry, material); + meshGroup.add(mesh); + return; + } + + const lineSegments = buildPrimitiveWireframeLines(geometry, params); + const allLines = [lineSegments]; + + if (params.renderStyle === 'Wireframe') { + if (params.occludeInner) { + const depthMaterial = new THREE.MeshBasicMaterial({ + colorWrite: false, + depthWrite: true, + depthTest: true, + side: THREE.DoubleSide + }); + const depthMesh = new THREE.Mesh(geometry, depthMaterial); + depthMesh.renderOrder = 0; + meshGroup.add(depthMesh); + + lineSegments.material.depthTest = true; + lineSegments.material.depthWrite = false; + lineSegments.renderOrder = 1; + } + + meshGroup.add(lineSegments); + + if (params.showOutline && camera) { + const outlineLines = createOutlineLines(meshGroup, allLines, camera, params); + outlineLines.forEach(line => meshGroup.add(line)); + } + return; + } + + if (params.renderStyle === 'Hidden-line') { + const depthMaterial = new THREE.MeshBasicMaterial({ + colorWrite: false, + depthWrite: true, + depthTest: true, + side: THREE.DoubleSide + }); + const depthMesh = new THREE.Mesh(geometry, depthMaterial); + depthMesh.renderOrder = 0; + meshGroup.add(depthMesh); + + lineSegments.material.depthTest = true; + lineSegments.material.depthWrite = false; + lineSegments.renderOrder = 1; + meshGroup.add(lineSegments); + + if (params.showOutline && camera) { + const outlineLines = createOutlineLines(meshGroup, allLines, camera, params); + outlineLines.forEach(line => meshGroup.add(line)); + } + } +} + function updateMesh(meshGroup, params, camera = null) { // Clear existing mesh meshGroup.clear(); + if ((params.geometryMode || 'tube') === 'primitive') { + updatePrimitiveMesh(meshGroup, params, camera); + return; + } + if (params.renderStyle === 'Solid') { // Solid rendering: use geometry meshes const outerGeometry = buildOuterTubeGeometry(params); diff --git a/v0.5/src/ui/dynamicControls.js b/v0.5/src/ui/dynamicControls.js index 96c6c07..06a6e28 100644 --- a/v0.5/src/ui/dynamicControls.js +++ b/v0.5/src/ui/dynamicControls.js @@ -120,6 +120,39 @@ function generateGeometryControls(params, onUpdate) { container.innerHTML = ''; + // Geometry mode selector + const modeSelectContainer = document.createElement('div'); + modeSelectContainer.className = 'control-group'; + const modeLabel = document.createElement('label'); + modeLabel.setAttribute('for', 'geometryMode'); + modeLabel.textContent = 'Geometry Mode'; + modeSelectContainer.appendChild(modeLabel); + + const modeSelect = document.createElement('select'); + modeSelect.id = 'geometryMode'; + [ + { value: 'tube', label: 'Parametric Tube' }, + { value: 'primitive', label: 'Primitive Shape' } + ].forEach(option => { + const optionEl = document.createElement('option'); + optionEl.value = option.value; + optionEl.textContent = option.label; + modeSelect.appendChild(optionEl); + }); + modeSelect.value = params.geometryMode || 'tube'; + modeSelect.addEventListener('change', (e) => { + params.geometryMode = e.target.value; + generateGeometryControls(params, onUpdate); + onUpdate(); + }); + modeSelectContainer.appendChild(modeSelect); + container.appendChild(modeSelectContainer); + + if ((params.geometryMode || 'tube') === 'primitive') { + buildPrimitiveControls(container, params, onUpdate); + return; + } + // Path type selector const pathSelectContainer = document.createElement('div'); pathSelectContainer.className = 'control-group'; @@ -337,3 +370,49 @@ function generateGeometryControls(params, onUpdate) { container.appendChild(vDivControl); } +function buildPrimitiveControls(container, params, onUpdate) { + // Primitive type selector + const primitiveSelectContainer = document.createElement('div'); + primitiveSelectContainer.className = 'control-group'; + const primitiveLabel = document.createElement('label'); + primitiveLabel.setAttribute('for', 'primitiveType'); + primitiveLabel.textContent = 'Primitive Type'; + primitiveSelectContainer.appendChild(primitiveLabel); + + const primitiveSelect = document.createElement('select'); + primitiveSelect.id = 'primitiveType'; + Object.keys(primitiveGenerators).forEach(key => { + const option = document.createElement('option'); + option.value = key; + option.textContent = primitiveGenerators[key].name; + primitiveSelect.appendChild(option); + }); + primitiveSelect.value = params.primitiveType || 'sphere'; + primitiveSelect.addEventListener('change', (e) => { + params.primitiveType = e.target.value; + generateGeometryControls(params, onUpdate); + onUpdate(); + }); + primitiveSelectContainer.appendChild(primitiveSelect); + container.appendChild(primitiveSelectContainer); + + // Primitive-specific parameter controls + const primitiveGen = getPrimitiveGenerator(params.primitiveType || 'sphere'); + if (primitiveGen && primitiveGen.params) { + Object.entries(primitiveGen.params).forEach(([key, config]) => { + addControl(container, key, config, params, onUpdate); + }); + } + + // Deformation controls + const deformHeader = document.createElement('h4'); + deformHeader.textContent = 'Deformations'; + deformHeader.style.marginTop = '20px'; + deformHeader.style.marginBottom = '10px'; + container.appendChild(deformHeader); + + Object.entries(primitiveDeformers).forEach(([key, config]) => { + addControl(container, key, config, params, onUpdate); + }); +} +