diff --git a/frontend/css/index.css b/frontend/css/index.css index c1b2db8..4d244ce 100644 --- a/frontend/css/index.css +++ b/frontend/css/index.css @@ -42,7 +42,7 @@ input:focus { padding 10px; width: 40px; height: 40px; - background: no-repeat center / 20px var(--white) url('/img/bars-black.svg'); + background: no-repeat center / 20px var(--white) url('../img/bars-black.svg'); transition: 0.2s; } @@ -52,7 +52,7 @@ input:focus { } #show-controls-button.selected { - background: no-repeat center / 20px var(--black) url('/img/bars-white.svg'); + background: no-repeat center / 20px var(--black) url('../img/bars-white.svg'); cursor: pointer; } @@ -92,7 +92,7 @@ input:focus { } #triangle-grid .triangle.selected { - background-image: url('/img/triangle.svg'); + background-image: url('../img/triangle.svg'); } #triangle-grid .triangle.selected:hover { @@ -100,7 +100,7 @@ input:focus { } #triangle-grid .triangle.selected.even-row { - background-image: url('/img/triangle-flipped.svg'); + background-image: url('../img/triangle-flipped.svg'); } .row { diff --git a/frontend/img/triangle-flipped.svg b/frontend/img/triangle-flipped.svg index 234d4a2..4494ebd 100644 --- a/frontend/img/triangle-flipped.svg +++ b/frontend/img/triangle-flipped.svg @@ -5,7 +5,7 @@ version="1.1" id="svg2" sodipodi:docname="triangle-flipped.svg" - inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" + inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -22,17 +22,21 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="7.1198816" - inkscape:cx="50.071057" + inkscape:cx="50.000831" inkscape:cy="43.259146" - inkscape:window-width="1904" - inkscape:window-height="995" - inkscape:window-x="8" - inkscape:window-y="40" - inkscape:window-maximized="0" - inkscape:current-layer="svg2" - inkscape:pageshadow="2" - showgrid="false" /> - + inkscape:window-width="1920" + inkscape:window-height="1008" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="svg2" /> + + + + + diff --git a/frontend/img/triangle.svg b/frontend/img/triangle.svg index 0deba55..062517e 100644 --- a/frontend/img/triangle.svg +++ b/frontend/img/triangle.svg @@ -5,7 +5,7 @@ version="1.1" id="svg2" sodipodi:docname="triangle.svg" - inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" + inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -22,17 +22,22 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="7.1198816" - inkscape:cx="50.071057" + inkscape:cx="50.000832" inkscape:cy="43.259146" - inkscape:window-width="1904" - inkscape:window-height="995" - inkscape:window-x="8" - inkscape:window-y="40" - inkscape:window-maximized="0" - inkscape:current-layer="svg2" - inkscape:pageshadow="2" - showgrid="false" /> - + inkscape:window-width="1920" + inkscape:window-height="1008" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="svg2" /> + + + + + diff --git a/frontend/index.html b/frontend/index.html index ad109a7..6530b0a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,6 +2,7 @@ + @@ -41,6 +42,11 @@ Simulate +
+
+ Download Nmesh File +
+
diff --git a/frontend/js/modules/matrix-lib.js b/frontend/js/modules/matrix-lib.js index 65028e1..cfae9bf 100644 --- a/frontend/js/modules/matrix-lib.js +++ b/frontend/js/modules/matrix-lib.js @@ -1,6 +1,10 @@ -// Multiply two matrices together using dot product multiplication -// -// a and b: matrices (2d array of numbers) +export const identity = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], +] + export const matrixMult = (a, b) => { if (a[0]?.length != b.length) { throw "incompatible dimensions" @@ -24,21 +28,6 @@ export const matrixMult = (a, b) => { return result } -// An identity matrix. -// This won't transform anything, so it's a good starting point -export const identity = [ - [1, 0, 0, 0], - [0, 1, 0, 0], - [0, 0, 1, 0], - [0, 0, 0, 1], -] - -// Create a matrix that will translate a -// coordinate in the x, y, and z dimensions -// -// x, y, and z: The amount to translate by (number) -// -// returns a matrix (2d array of numbers) export const translate = (x, y, z) => [ [1, 0, 0, 0], [0, 1, 0, 0], @@ -46,12 +35,6 @@ export const translate = (x, y, z) => [ [x, y, z, 1], ] -// Create a matrix that will scale a coordinate -// in the x, y, and z dimensions -// -// x, y, and z: the amount to scale by (number) -// -// returns a matrix (2d array of numbers) export const scale = (x, y = x, z = x) => [ [x, 0, 0, 0], [0, y, 0, 0], @@ -59,12 +42,6 @@ export const scale = (x, y = x, z = x) => [ [0, 0, 0, 1], ] -// Create a matrix that will rotate a coordinate -// along the x, y, and z axes -// -// x, y, and z: the amount to rotate by (number) -// -// returns a matrix (2d array of numbers) export const rotate = (x, y, z) => { // X rotation @@ -105,13 +82,6 @@ export const rotate = (x, y, z) => { return matrixMult(matrixMult(xMat, yMat), zMat) } -// Creates a matrix that can project a matrix -// in 3d space to create a sense of perspective -// -// scale: Intensity of the effect -// aspect: Ratio of the width and height for the viewport -// -// returns a matrix (2d array of numbers) export const project = (scale, aspect) => [ [aspect > 1 ? 1 / aspect : 1, 0, 0, 0], [0, aspect < 1 ? aspect : 1, 0, 0], diff --git a/frontend/js/modules/model-utils.js b/frontend/js/modules/model-utils.js index 33e9f32..ba96ca6 100644 --- a/frontend/js/modules/model-utils.js +++ b/frontend/js/modules/model-utils.js @@ -1,10 +1,11 @@ +// TODO: fix this so that exterior is not always true export const extrudePoints = (points, layers) => Array(layers).fill(0) .map((_, z) => points .map((row, y) => row .map((p, x) => ({ ...p, z, - exterior: // TODO: fix this so that exterior is not always true + exterior: !points[0][x - 1]?.[y] || !points[0][x + 1]?.[y] || !points[0][x]?.[y - 1] diff --git a/frontend/js/modules/scene.js b/frontend/js/modules/scene.js index 902514e..d9dd412 100644 --- a/frontend/js/modules/scene.js +++ b/frontend/js/modules/scene.js @@ -106,7 +106,7 @@ export default class Scene { return this } - + /** * Draws lines using the given positions and colors. * @param {Float32Array} positions - The vertex positions. diff --git a/frontend/js/new.js b/frontend/js/new.js new file mode 100644 index 0000000..ff1c4ee --- /dev/null +++ b/frontend/js/new.js @@ -0,0 +1,306 @@ +import { $ } from './modules/common.js' +import Scene from './modules/scene.js' +import { svg, path, svg2points } from './modules/svg-lib.js' +import { triangle, invertedTriangle, arrangement } from './modules/gates.js' +import { + extrudePoints, + createTetrahedrons, + drawModel, + centerScene, + arrangeModel, +} from './modules/model-utils.js' + +const scene = new Scene() + .project(2, $('canvas').width / $('canvas').height) + +/** + * @typedef {Object} Point + * @property {boolean} exterior - If this point is visible when rendering. + * @property {number} x - The x coordinate of the point. + * @property {number} y - The y coordinate of the point. + * @property {number} z - The z coordinate of the point. + */ + +/** + * @type {Array>} + * An array of tetrahedrons, where each tetrahedron is an + * array of 4 point objects. Each point is an object with + * `x`, `y`, and `z` properties representing the 3D coordinates. +*/ +let tetrahedrons = [] + +const renderMesh = async (positionGrid) => { + const componentModel = await (await fetch(`${location.origin}/res/triangle.json`)).json() + tetrahedrons = arrangeModel(positionGrid, componentModel) + centerScene(scene, tetrahedrons) + drawModel(scene, tetrahedrons) +} + +// Triangle grid controls + +const toggleControls = ({ target }) => { + if (target.classList.contains('selected')) { + target.classList.remove('selected') + $('#show-controls-target').classList.add('hidden') + } else { + target.classList.add('selected') + $('#show-controls-target').classList.remove('hidden') + } +} + +const getPositionGrid = () => { + let isEvenRow = false + const positionGrid = [[]] + + Array.from($('#triangle-grid').children).forEach(c => { + if (isEvenRow != c.classList.contains('even-row')) { + positionGrid.unshift([]) + isEvenRow = c.classList.contains('even-row') + } + + positionGrid[0] + .push(c.classList.contains('selected')) + }) + + return positionGrid +} + +const toggleTriangle = ({ target }) => { + target.classList.contains('selected') + ? target.classList.remove('selected') + : target.classList.add('selected') + + renderMesh(getPositionGrid()) +} + +const makeTriangleGrid = (positionGrid) => { + const rows = positionGrid.length + const cols = positionGrid[0].length + + $('#triangle-grid').innerHTML = ''; + $('#triangle-grid').style['grid-template-columns'] = + Array(cols).fill(0).map(_ => 'auto').join(' ') + + // Rows are read backwards to match the drawing coordinate system + for (let i = rows - 1; i >= 0; i--) { + for (let j = 0; j < cols; j++) { + const cell = document.createElement('div') + + cell.classList.add(...[ + 'triangle', + (i + 1) % 2 == 0 && 'even-row', + positionGrid[i][j] && 'selected', + ].filter(c => c)) + + cell.onclick = toggleTriangle + + $('#triangle-grid').appendChild(cell) + } + } + + renderMesh(positionGrid) +} + +const changeNumRows = ({ target }) => { + if (target.value < 1) target.value = 1 + + const rows = target.value + const cols = $('#cols-input').value + const positionGrid = getPositionGrid() + + while (positionGrid.length > rows) + positionGrid.splice(-1) + + while (positionGrid.length < rows) + positionGrid.push(Array(cols).fill(0)) + + makeTriangleGrid(positionGrid) +} + +const changeNumCols = ({ target }) => { + if (target.value < 1) target.value = 1 + + const rows = $('#rows-input').value + const cols = target.value + const positionGrid = getPositionGrid() + + while (positionGrid[0].length > cols) + positionGrid.forEach(row => row.splice(-1)) + + while (positionGrid[0].length < cols) + positionGrid.forEach(row => row.push(0)) + + makeTriangleGrid(positionGrid) +} + +/** + * Onclick function for converting tetrahedrons to Nmesh file. + * I haven't stress tested it, but it should fail when we have around a couple + * million tetrahedrons. More efficient approaches will have to be considered then. + * https://nmag.readthedocs.io/en/latest/finite_element_mesh_generation.html#nmesh-file-format + * @param {Event} e + */ +const downloadNmeshFile = (e) => { + $('#download-nmesh-file').innerHTML = 'Generating...' + // Delimiter to use for point -> key generation + const DELIM = ',' + + /** + * @param {Point} point + */ + const generateKeyFromPoint = (point) => `${point.x}${DELIM}${point.y}${DELIM}${point.z}`; + + /** @type {Array} + * An array to store unique nodes (points). + */ + const nodes = [] + + /** + * @type {Map} + * A map to track unique nodes by their stringified coordinates, mapping to a node index. + */ + const nodeMap = new Map() + + /** @type {number} + * The index to assign to the next unique node. + */ + let nodeIndex = 0 + + /** @type {Array>} + * An array to store tetrahedrons as arrays of node indices. + * The inner array is always of length 4 and contains the node index for each point. + */ + const simplices = [] + + // Generate nodes and unique indexes for the node map + tetrahedrons.forEach((tetrahedron) => { + tetrahedron.forEach((point) => { + const key = generateKeyFromPoint(point) + if(!nodeMap.has(key)) { + nodeMap.set(key, nodeIndex) + nodes.push(point) + nodeIndex++ + } + }) + }) + + // Generate simplices + tetrahedrons.forEach((tetrahedron) => { + const tetrahedronAsNodeIndexes = tetrahedron.map((point) => nodeMap.get(generateKeyFromPoint(point))) + simplices.push(tetrahedronAsNodeIndexes); + }) + + /** @type {string} The content of the `.nmesh` file as a string. */ + let nmeshFileContent = '# PYFEM mesh file version 1.0\n' + + // TODO: If we aren't including surfaces in our file, do we need to include it here? + nmeshFileContent += `# dim = 3 nodes = ${nodes.length} simplices = ${simplices.length} periodic = 0\n` + + nmeshFileContent += `${nodes.length}\n`; + nodes.forEach((node, i) => { + nmeshFileContent += ` ${node.x} ${node.y} ${node.z}\n` + }) + + nmeshFileContent += `${simplices.length}\n`; + simplices.forEach((simplex) => { + nmeshFileContent += ` 1 ${simplex.join(' ')}\n`; // Format simplex in NMesh format + }); + + const nmeshFileBlob = new Blob([nmeshFileContent], {type: 'text/plain'}) + + const a = document.createElement('a') + a.href = URL.createObjectURL(nmeshFileBlob) + a.download = 'generated.nmesh' + a.style.display = 'none' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + + $('#download-nmesh-file').innerHTML = 'Download Nmesh File' +}; + +$('#show-controls-button').onclick = toggleControls +$('#rows-input').onclick = changeNumRows +$('#rows-input').onblur = changeNumRows +$('#rows-input').onkeypress = (e) => e.key == 'Enter' ? changeNumRows(e) : e +$('#cols-input').onclick = changeNumCols +$('#cols-input').onblur = changeNumCols +$('#cols-input').onkeypress = (e) => e.key == 'Enter' ? changeNumCols(e) : e +$('#download-nmesh-file').onclick = downloadNmeshFile + +// Initial state + +$('#rows-input').value = 3 +$('#cols-input').value = 3 + +makeTriangleGrid([ + [0, 1, 0], + [0, 1, 0], + [1, 0, 1], +]) + +// Limit rendering calls to improve efficiency + +const timeBetweenDraws = 30 +let lastDrawTime = 0 + +const canDraw = () => { + if (Date.now() - lastDrawTime < timeBetweenDraws) { + return false + } + + lastDrawTime = Date.now() + + return true +} + +// Tranformation controls + +$('main').onmousedown = () => $('main').isClicked = true +$('main').onmouseup = () => $('main').isClicked = false +$('main').onmouseout = () => $('main').isClicked = false + +$('main').onmousemove = e => { + if (!$('main').isClicked) return + if (!canDraw()) return + + scene.clear() + + // Click and drag to rotate + !e.shiftKey && scene.rotate( + Math.PI * e.movementY / 100, + Math.PI * e.movementX / 100, + 0, + ) + + // Shift-Click and drag to translate + e.shiftKey && scene.translate( + 4.5 * e.movementX / $('main').clientWidth, + -4.5 * e.movementY / $('main').clientHeight, + 0, + ) + + drawModel(scene, tetrahedrons) +} + +// Mouse-wheel to scale +$('main').onwheel = e => { + if (!canDraw()) return + + scene.clear() + scene.scale(e.deltaY > 0 ? 1.15 : 0.85) + drawModel(scene, tetrahedrons) +} + +// Resize canvas when it's containers size changes +setInterval(() => { + if ( + $('canvas').width == $('main').clientWidth + && $('canvas').height == $('main').clientHeight + ) return + + scene + .resizeCanvas($('main').clientWidth, $('main').clientHeight) + .project(2, $('canvas').width / $('canvas').height) + drawModel(scene, tetrahedrons) +}, 100) diff --git a/server.py b/server.py new file mode 100644 index 0000000..9f29865 --- /dev/null +++ b/server.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +import http.server +import socketserver +import os + +PORT = 1234 +HTML_FOLDER_PATH = './frontend' + +class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=HTML_FOLDER_PATH, **kwargs) + + def do_GET(self): + if self.path == '/': + self.send_response(301) + self.send_header('Location', '/index.html') + self.end_headers() + else: + super().do_GET() + +with socketserver.TCPServer(("", PORT), CustomHTTPRequestHandler) as httpd: + print(f"Serving on port {PORT} from {os.path.abspath(HTML_FOLDER_PATH)}") + httpd.serve_forever()