diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..500293c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: '/bampy' + schedule: + interval: daily + time: '08:00' + open-pull-requests-limit: 10 diff --git a/bampy/.gitignore b/bampy/.gitignore new file mode 100644 index 0000000..4e30131 --- /dev/null +++ b/bampy/.gitignore @@ -0,0 +1,6 @@ +/target +**/*.rs.bk +Cargo.lock +bin/ +pkg/ +wasm-pack.log diff --git a/bampy/Cargo.toml b/bampy/Cargo.toml new file mode 100644 index 0000000..44e85b8 --- /dev/null +++ b/bampy/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "bampy" +version = "0.1.0" +authors = ["Thea Schöbl "] +edition = "2018" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +wasm-bindgen = "0.2.84" + +# The `console_error_panic_hook` crate provides better debugging of panics by +# logging them with `console.error`. This is great for development, but requires +# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for +# code size when deploying. +console_error_panic_hook = { version = "0.1.7", optional = true } +bvh = "0.8.0" +nalgebra = "0.32.4" +num = "0.4.1" + +[dependencies.getrandom] +features = ["js"] + +[dev-dependencies] +wasm-bindgen-test = "0.3.34" + +[profile.release] +# Tell `rustc` to optimize for small code size. +opt-level = "s" diff --git a/bampy/src/lib.rs b/bampy/src/lib.rs new file mode 100644 index 0000000..b6f93ea --- /dev/null +++ b/bampy/src/lib.rs @@ -0,0 +1,46 @@ +use bvh::{aabb::Bounded, bvh::Bvh}; +use nalgebra::Point; +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::slicer::{base_slices, SlicerOptions, Triangle}; + +mod slicer; +mod util; + +#[wasm_bindgen] +pub fn slice(positions: &[f32], face_normals: &[f32], layer_height: f32) { + debug_assert_eq!(positions.len() % 9, 0); + debug_assert_eq!(face_normals.len() % 3, 0); + debug_assert_eq!(positions.len() / 9, face_normals.len() / 3); + + let mut triangles = Vec::with_capacity(positions.len() / 9); + for i in (0..positions.len()).step_by(9) { + let triangle = Triangle { + a: Point::::new(positions[i], positions[i + 1], positions[i + 2]), + b: Point::::new(positions[i + 3], positions[i + 4], positions[i + 5]), + c: Point::::new(positions[i + 6], positions[i + 7], positions[i + 8]), + normal: Point::::new( + face_normals[i / 9], + face_normals[i / 9 + 1], + face_normals[i / 9 + 2], + ), + node_index: 0, + }; + triangles.push(triangle); + } + + let mut aabb = triangles[0].aabb(); + for triangle in &triangles { + aabb.grow_mut(&triangle.a); + aabb.grow_mut(&triangle.b); + aabb.grow_mut(&triangle.c); + } + + let slicer_options = SlicerOptions { + aabb, + bvh: Bvh::build(&mut triangles), + triangles, + layer_height, + }; + let base_slices = base_slices::create_base_slices(&slicer_options, &vec![]); +} diff --git a/bampy/src/slicer/base_slices.rs b/bampy/src/slicer/base_slices.rs new file mode 100644 index 0000000..15ed047 --- /dev/null +++ b/bampy/src/slicer/base_slices.rs @@ -0,0 +1,60 @@ +use bvh::bvh::BvhNode; +use nalgebra::Point; + +use super::{Line, SlicerOptions}; + +#[derive(Debug)] +pub struct BaseSlice { + z: f32, + lines: Vec>, +} + +/** + * Creates base slices from the geometry, excluding surfaces. + * + * The slicse are not sorted or separated into rings. + */ +pub fn create_base_slices(options: &SlicerOptions, surface_triangles: &[bool]) -> Vec { + let layer_count = f32::floor(options.aabb.max.z / options.layer_height) as usize; + let mut base_slices = Vec::::with_capacity(layer_count); + + for i in 0..layer_count { + let layer = i as f32 * options.layer_height; + let base_slice = BaseSlice { + z: layer, + lines: vec![], + }; + + let mut stack = Vec::::with_capacity(options.bvh.nodes.len()); + stack.push(0); + while let Some(i) = stack.pop() { + match options.bvh.nodes[i] { + BvhNode::Node { + parent_index: _, + child_l_index, + child_l_aabb, + child_r_index, + child_r_aabb, + } => { + if layer >= child_l_aabb.min.z && layer <= child_l_aabb.max.z { + stack.push(child_r_index); + } + if layer >= child_r_aabb.min.z && layer <= child_r_aabb.max.z { + stack.push(child_l_index); + } + } + BvhNode::Leaf { + parent_index: _, + shape_index, + } => { + let triangle = options.triangles[shape_index]; + let a = Point::::new(triangle.a.x, triangle.a.y, layer); + } + } + } + + base_slices.push(base_slice); + } + + base_slices +} diff --git a/bampy/src/slicer/mod.rs b/bampy/src/slicer/mod.rs new file mode 100644 index 0000000..c9e3575 --- /dev/null +++ b/bampy/src/slicer/mod.rs @@ -0,0 +1,52 @@ +use std::fmt::Debug; + +use bvh::{ + aabb::{Aabb, Bounded}, + bounding_hierarchy::BHShape, + bvh::Bvh, +}; +use nalgebra::{Point, Scalar, SimdPartialOrd}; + +pub mod base_slices; + +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct Triangle { + pub a: Point, + pub b: Point, + pub c: Point, + pub normal: Point, + pub node_index: usize, +} + +impl Bounded for Triangle { + fn aabb(&self) -> Aabb { + let mut aabb = self.a.aabb(); + aabb.grow_mut(&self.b); + aabb.grow_mut(&self.c); + aabb + } +} + +impl BHShape for Triangle { + fn set_bh_node_index(&mut self, node_index: usize) { + self.node_index = node_index; + } + + fn bh_node_index(&self) -> usize { + self.node_index + } +} + +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct Line { + pub start: Point, + pub end: Point, +} + +#[derive(Debug)] +pub struct SlicerOptions { + pub aabb: bvh::aabb::Aabb, + pub bvh: Bvh, + pub triangles: Vec>, + pub layer_height: f32, +} diff --git a/bampy/src/util.rs b/bampy/src/util.rs new file mode 100644 index 0000000..1e31335 --- /dev/null +++ b/bampy/src/util.rs @@ -0,0 +1,18 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + pub fn log(s: &str); + + #[wasm_bindgen(js_namespace = console, js_name = log)] + pub fn log_u32(a: u32); + + #[wasm_bindgen(js_namespace = console, js_name = log)] + pub fn log_many(a: &str, b: &str); +} + +#[macro_export] +macro_rules! console_log { + ($($t:tt)*) => (crate::util::log(&format_args!($($t)*).to_string())) +} diff --git a/bampy/tests/web.rs b/bampy/tests/web.rs new file mode 100644 index 0000000..de5c1da --- /dev/null +++ b/bampy/tests/web.rs @@ -0,0 +1,13 @@ +//! Test suite for the Web and headless browsers. + +#![cfg(target_arch = "wasm32")] + +extern crate wasm_bindgen_test; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +fn pass() { + assert_eq!(1 + 1, 2); +} diff --git a/flake.nix b/flake.nix index 88fabb7..2f78f9d 100644 --- a/flake.nix +++ b/flake.nix @@ -14,6 +14,7 @@ overlays = [(import rust-overlay)]; pkgs = import nixpkgs {inherit system overlays;}; rust-bin = pkgs.rust-bin.stable.latest.default.override { + targets = [ "wasm32-unknown-unknown" ]; extensions = ["rust-src" "rust-std" "clippy" "rust-analyzer"]; }; fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff])); @@ -34,6 +35,7 @@ nodePackages.pnpm rust-bin fontMin + wasm-pack ]) ++ (with tauriPkgs; [ curl diff --git a/package.json b/package.json index ee805b3..e3f2837 100644 --- a/package.json +++ b/package.json @@ -1,37 +1,40 @@ { - "name": "bampy", - "version": "0.0.1", - "private": true, - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check .", - "format": "prettier --write ." - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^3.0.0", - "@sveltejs/kit": "^2.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "@types/three": "^0.159.0", - "prettier": "^3.1.1", - "prettier-plugin-svelte": "^3.1.2", - "svelte": "^4.2.7", - "svelte-check": "^3.6.0", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.0.3" - }, - "type": "module", - "dependencies": { - "@dimforge/rapier3d": "^0.12.0", - "@threlte/core": "^7.1.2", - "@threlte/extras": "^8.8.1", - "sass": "^1.71.1", - "three": "^0.159.0", - "three-mesh-bvh": "^0.7.3", - "vite-plugin-wasm": "^3.3.0" - } -} \ No newline at end of file + "name": "bampy", + "version": "0.0.1", + "private": true, + "scripts": { + "wasm": "wasm-pack build ./bampy --target web", + "dev": "pnpm wasm && vite dev", + "build": "pnpm wasm && vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check .", + "format": "prettier --write ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@types/three": "^0.159.0", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "svelte": "^4.2.7", + "svelte-check": "^3.6.0", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^5.0.3", + "vite-plugin-wasm-pack": "^0.1.12" + }, + "type": "module", + "dependencies": { + "@dimforge/rapier3d": "^0.12.0", + "@threlte/core": "^7.1.2", + "@threlte/extras": "^8.8.1", + "sass": "^1.71.1", + "three": "^0.159.0", + "three-mesh-bvh": "^0.7.3", + "vite-plugin-wasm": "^3.3.0" + } +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1b911b..b3a4d6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,9 @@ devDependencies: vite: specifier: ^5.0.3 version: 5.1.4(sass@1.71.1) + vite-plugin-wasm-pack: + specifier: ^0.1.12 + version: 0.1.12 packages: @@ -529,6 +532,13 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -582,6 +592,14 @@ packages: engines: {node: '>=6'} dev: true + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + /chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -605,6 +623,17 @@ packages: estree-walker: 3.0.3 periscopic: 3.1.0 + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true @@ -721,6 +750,15 @@ packages: dependencies: to-regex-range: 5.0.1 + /fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + dev: true + /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true @@ -761,6 +799,11 @@ packages: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + /immutable@4.3.5: resolution: {integrity: sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==} @@ -812,6 +855,14 @@ packages: dependencies: '@types/estree': 1.0.5 + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + /kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -891,6 +942,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /narrowing@1.5.0: + resolution: {integrity: sha512-DUu4XdKgkfAPTAL28k79pdnshDE2W5T24QAnidSPo2F/W1TX6CjNzmEeXQfE5O1lxQvC0GYI6ZRDsLcyzugEYA==} + dev: true + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -1070,6 +1125,13 @@ packages: min-indent: 1.0.1 dev: true + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + /svelte-check@3.6.6(sass@1.71.1)(svelte@4.2.12): resolution: {integrity: sha512-b9q9rOHOMYF3U8XllK7LmXTq1LeWQ98waGfEJzrFutViadkNl1tgdEtxIQ8yuPx+VQ4l7YrknYol+0lfZocaZw==} hasBin: true @@ -1275,6 +1337,19 @@ packages: hasBin: true dev: true + /universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + dev: true + + /vite-plugin-wasm-pack@0.1.12: + resolution: {integrity: sha512-WliYvQp9HXluir4OKGbngkcKxtYtifU11cqLurRRJGsl770Sjr1iIkp5RuvU3IC1poT4A57Z2/YgAKI2Skm7ZA==} + dependencies: + chalk: 4.1.2 + fs-extra: 10.1.0 + narrowing: 1.5.0 + dev: true + /vite-plugin-wasm@3.3.0(vite@5.1.4): resolution: {integrity: sha512-tVhz6w+W9MVsOCHzxo6SSMSswCeIw4HTrXEi6qL3IRzATl83jl09JVO1djBqPSwfjgnpVHNLYcaMbaDX5WB/pg==} peerDependencies: diff --git a/src/lib/components/Scene.svelte b/src/lib/components/Scene.svelte index 9ef3a39..ae04b2f 100644 --- a/src/lib/components/Scene.svelte +++ b/src/lib/components/Scene.svelte @@ -7,11 +7,9 @@ BufferGeometry, MathUtils, Vector3, - Mesh, DoubleSide, Color, - BufferGeometryLoader, - ConeGeometry + BufferGeometryLoader } from 'three'; import { writable } from 'svelte/store'; import { onDestroy, onMount } from 'svelte'; diff --git a/src/lib/slicer/slicer.ts b/src/lib/slicer/slicer.ts new file mode 100644 index 0000000..b3f0183 --- /dev/null +++ b/src/lib/slicer/slicer.ts @@ -0,0 +1,35 @@ +import { + BufferAttribute, + BufferGeometry, + BufferGeometryLoader, + InterleavedBufferAttribute, + Vector3 +} from 'three'; +import type { SliceArguments } from './worker-data'; +import { MeshBVH } from 'three-mesh-bvh'; + +export class SlicerOptions { + readonly bedNormal: Vector3; + readonly maxNonPlanarAngle: number; + readonly tolerance: number; + readonly layerHeight: number; + + readonly geometry: BufferGeometry; + readonly bvh: MeshBVH; + readonly positions: BufferAttribute | InterleavedBufferAttribute; + readonly normals: BufferAttribute | InterleavedBufferAttribute; + readonly index: BufferAttribute; + + constructor(options: SliceArguments) { + this.bedNormal = new Vector3(...options.bedNormal); + this.maxNonPlanarAngle = options.maxNonPlanarAngle; + this.tolerance = options.tolerance; + this.layerHeight = options.layerHeight; + + this.geometry = new BufferGeometryLoader().parse(options.stl); + this.bvh = new MeshBVH(this.geometry); + this.positions = this.geometry.getAttribute('position'); + this.normals = this.geometry.getAttribute('normal'); + this.index = this.geometry.index!; + } +} diff --git a/src/lib/slicer/steps/extract-layers.ts b/src/lib/slicer/steps/extract-layers.ts new file mode 100644 index 0000000..fe4d984 --- /dev/null +++ b/src/lib/slicer/steps/extract-layers.ts @@ -0,0 +1,159 @@ +import { BufferGeometry, Float32BufferAttribute, Line3, Matrix4, Plane, Vector3 } from 'three'; +import type { SlicerOptions } from '../slicer'; +import type { HitPointInfo, MeshBVH } from 'three-mesh-bvh'; +import { LayerType, type LayerMessage, type ProgressMessage } from '../worker-data'; + +type Withheld = Array< + { type: LayerType.Line; geometry: number[] } | { type: LayerType.Surface; id: [number, MeshBVH] } +>[]; + +function deactivateSurface(this: Withheld, surface: MeshBVH, index: number) { + self.postMessage({ + type: 'layer', + data: { type: LayerType.Surface, geometry: surface.geometry.toJSON() } + } satisfies LayerMessage); + + for (const thing of this[index]) { + if (thing.type === LayerType.Line) { + if (thing.geometry.length === 0) continue; + const additionalGeometry = new BufferGeometry(); + additionalGeometry.setAttribute('position', new Float32BufferAttribute(thing.geometry, 3)); + self.postMessage({ + type: 'layer', + data: { type: LayerType.Line, geometry: additionalGeometry.toJSON() } + }); + } else if (thing.type === LayerType.Surface) { + deactivateSurface.call(this, thing.id[1], thing.id[0]); + } + } + delete this[index]; +} + +const line = new Line3(); +function intersect(layerPlane: Plane, a: Vector3, b: Vector3, targetVector: Vector3) { + line.set(a, b); + return layerPlane.intersectLine(line, targetVector); +} + +export function extractLayers( + options: SlicerOptions, + surfaces: MeshBVH[], + surfaceTriangles: boolean[] +) { + const targetVector1 = new Vector3(); + const targetVector2 = new Vector3(); + const targetVector3 = new Vector3(); + const hit1: HitPointInfo = { point: new Vector3(), distance: 0, faceIndex: 0 }; + const hit2: HitPointInfo = { point: new Vector3(), distance: 0, faceIndex: 0 }; + const layerPlane = new Plane(); + + const activeNonPlanarSurfaces: [number, MeshBVH][] = []; + const consumedNonPlanarSurfaces = surfaces.map(() => false); + const withheld: Withheld = surfaces.map(() => [{ type: LayerType.Line, geometry: [] }]); + const blacklist = Array.from({ length: options.index.count / 3 }).map(() => false); + + for (let layer = 0; layer < options.geometry.boundingBox!.max.z; layer += options.layerHeight) { + layerPlane.set(options.bedNormal, -layer); + const layerGeometry = new BufferGeometry(); + const positions: number[] = []; + for (let i = 0; i < surfaces.length; i++) { + if (consumedNonPlanarSurfaces[i]) continue; + if (layer >= surfaces[i].geometry.boundingBox!.min.z) { + consumedNonPlanarSurfaces[i] = true; + activeNonPlanarSurfaces.push([i, surfaces[i]]); + } + } + deactivate: for (let i = 0; i < activeNonPlanarSurfaces.length; i++) { + const [index, surface] = activeNonPlanarSurfaces[i]; + if (layer > surface.geometry.boundingBox!.max.z) { + activeNonPlanarSurfaces.splice(i, 1); + i--; + + for (const [activeIndex, active] of activeNonPlanarSurfaces) { + if (activeIndex === index) continue; + const hit = active.closestPointToGeometry(surface.geometry, new Matrix4(), hit1, hit2); + if ( + hit && + hit1.point.z < hit2.point.z && + Math.abs(Math.PI / 2 - hit1.point.clone().sub(hit2.point).angleTo(options.bedNormal)) > + options.maxNonPlanarAngle + ) { + withheld[activeIndex].push({ type: LayerType.Surface, id: [index, surface] }); + withheld[activeIndex].push({ type: LayerType.Line, geometry: [] }); + continue deactivate; + } + } + deactivateSurface.call(withheld, surface, index); + } + withheld[index]?.push({ type: LayerType.Line, geometry: [] }); + } + + options.bvh.shapecast({ + intersectsBounds(box, _isLeaf, _score, _depth, _nodeIndex) { + return layerPlane.intersectsBox(box); + }, + intersectsTriangle(target, triangleIndex, _contained, _depth) { + if (surfaceTriangles[triangleIndex] || blacklist[triangleIndex]) return; + const targets = [target.a, target.b, target.c]; + const items = [targetVector1, targetVector2, targetVector3]; + + let a: Vector3 | null = intersect(layerPlane, targets[0], targets[1], targetVector1); + let b: Vector3 | null = null; + for (let i = 0; i < 3; i++) { + const i1 = (i + 1) % 3; + a = b; + b = intersect( + layerPlane, + targets[i1], + targets[(i1 + 1) % 3], + i % 2 === 0 ? targetVector2 : targetVector1 + ); + if (!a || !b) continue; + + for (let i = 0; i < activeNonPlanarSurfaces.length; i++) { + const [index, surface] = activeNonPlanarSurfaces[i]; + const withheldLayer = withheld[index].at(-1)!; + if (withheldLayer.type === LayerType.Surface) throw new Error('Unexpected surface'); + const h1 = surface.closestPointToPoint(a); + if ( + h1 && + h1.point.z < a.z && + Math.abs(Math.PI / 2 - h1.point.clone().sub(a).angleTo(options.bedNormal)) > + options.maxNonPlanarAngle + ) { + withheldLayer.geometry.push(a.x, a.y, a.z, b.x, b.y, b.z); + return; + } + const h2 = surface.closestPointToPoint(b); + if ( + h2 && + h2.point.z < b.z && + Math.abs(Math.PI / 2 - h2.point.clone().sub(b).angleTo(options.bedNormal)) > + options.maxNonPlanarAngle + ) { + withheldLayer.geometry.push(a.x, a.y, a.z, b.x, b.y, b.z); + return; + } + } + positions.push(a.x, a.y, a.z, b.x, b.y, b.z); + return; + } + } + }); + layerGeometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + + self.postMessage({ + type: 'layer', + data: { type: LayerType.Line, geometry: layerGeometry.toJSON() } + } satisfies LayerMessage); + self.postMessage({ + type: 'progress', + percent: layer / options.geometry.boundingBox!.max.z, + layer: Math.round(layer / options.layerHeight) + } satisfies ProgressMessage); + } + + for (const [index, surface] of activeNonPlanarSurfaces) { + deactivateSurface.call(withheld, surface, index); + } +} diff --git a/src/lib/slicer/steps/extract-surfaces.ts b/src/lib/slicer/steps/extract-surfaces.ts new file mode 100644 index 0000000..f5659a2 --- /dev/null +++ b/src/lib/slicer/steps/extract-surfaces.ts @@ -0,0 +1,88 @@ +import { Vector3, BufferGeometry } from 'three'; +import { type SlicerOptions } from '../slicer'; +import { ExtendedTriangle, MeshBVH } from 'three-mesh-bvh'; + +/** + * Extracts all continuous surfaces that can be printed at the specified angle. + */ +export function extractSurfaces( + options: SlicerOptions +): [surfaces: MeshBVH[], surfaceTriangles: boolean[]] { + const qualifyingTriangles = Array.from({ length: options.index.count / 3 }, () => false); + let qualifyingTrianglesCount = 0; + const triangle = new ExtendedTriangle(); + const normal = new Vector3(); + for (let i = 0; i < options.index.count / 3; i++) { + triangle.setFromAttributeAndIndices( + options.positions, + options.index.array[i * 3], + options.index.array[i * 3 + 1], + options.index.array[i * 3 + 2] + ); + triangle.getNormal(normal); + const angle = normal.angleTo(options.bedNormal); + // TODO: bottom layers + if (angle < options.maxNonPlanarAngle) { + qualifyingTriangles[i] = true; + qualifyingTrianglesCount++; + } + } + + const surfaceTriangles = [...qualifyingTriangles]; + + const surfaces: number[][] = []; + while (qualifyingTrianglesCount > 0) { + const faceIndex = qualifyingTriangles.findIndex((it) => it); + qualifyingTriangles[faceIndex] = false; + qualifyingTrianglesCount--; + const surface = [faceIndex]; + let cursor = 0; + while (cursor < surface.length) { + triangle.setFromAttributeAndIndices( + options.positions, + options.index.array[surface[cursor] * 3], + options.index.array[surface[cursor] * 3 + 1], + options.index.array[surface[cursor] * 3 + 2] + ); + + options.bvh.shapecast({ + intersectsBounds(box, _isLeaf, _score, _depth, _nodeIndex) { + return triangle.intersectsBox(box); + }, + intersectsTriangle(target, triangleIndex, _contained, _depth) { + if ( + qualifyingTriangles[triangleIndex] && + target.distanceToTriangle(triangle) < options.tolerance + ) { + qualifyingTriangles[triangleIndex] = false; + qualifyingTrianglesCount--; + surface.push(triangleIndex); + } + } + }); + + cursor++; + } + surfaces.push(surface); + } + + return [ + surfaces.map((surface) => { + const geometry = new BufferGeometry(); + geometry.setAttribute('position', options.positions); + geometry.setAttribute('normal', options.normals); + const indices: number[] = Array.from({ length: surface.length * 3 }); + for (let i = 0; i < surface.length; i++) { + const pos = surface[i] * 3; + indices[i * 3] = options.index.array[pos]; + indices[i * 3 + 1] = options.index.array[pos + 1]; + indices[i * 3 + 2] = options.index.array[pos + 2]; + } + geometry.setIndex(indices); + const bvh = new MeshBVH(geometry); + geometry.boundsTree = bvh; + return bvh; + }), + surfaceTriangles + ]; +} diff --git a/src/lib/slicer/steps/slice.ts b/src/lib/slicer/steps/slice.ts new file mode 100644 index 0000000..2bf46a5 --- /dev/null +++ b/src/lib/slicer/steps/slice.ts @@ -0,0 +1,135 @@ +import { Plane, Line3, Vector3 } from 'three'; +import type { SlicerOptions } from '../slicer'; +import { approxEquals } from '../util/equals'; + +/** + * A continuous 2d ring of points + * + * No matter how you slice, on a model without holes all points will + * form continous rings. + */ +export interface ShellRing { + /** + * The plane the ring is on + */ + plane: Plane; + + /** + * Points of the ring + */ + points: Vector3[]; +} + +interface BaseSlice { + /** + * The plane the slice is on + */ + plane: Plane; + + /** + * The lines of the slice (not sorted!) + */ + lines: Line3[]; +} + +const line = new Line3(); +function intersect(layerPlane: Plane, a: Vector3, b: Vector3, targetVector: Vector3) { + line.set(a, b); + return layerPlane.intersectLine(line, targetVector); +} + +/** + * Creates base slices from the geometry, excluding surfaces. + * + * The slicse are not sorted or separated into rings. + */ +function createBaseSlices(options: SlicerOptions, surfaceTriangles: boolean[]): BaseSlice[] { + const targetVector1 = new Vector3(); + const targetVector2 = new Vector3(); + const targetVector3 = new Vector3(); + + const baseSlices: BaseSlice[] = []; + + for (let layer = 0; layer < options.geometry.boundingBox!.max.z; layer += options.layerHeight) { + const baseSlice: BaseSlice = { + plane: new Plane(options.bedNormal, -layer), + lines: [] + }; + + options.bvh.shapecast({ + intersectsBounds(box, _isLeaf, _score, _depth, _nodeIndex) { + return baseSlice.plane.intersectsBox(box); + }, + intersectsTriangle(target, triangleIndex, _contained, _depth) { + if (surfaceTriangles[triangleIndex]) return; + const intersections = [ + intersect(baseSlice.plane, target.a, target.b, targetVector1), + intersect(baseSlice.plane, target.b, target.c, targetVector2), + intersect(baseSlice.plane, target.c, target.a, targetVector3) + ]; + + for (let i = 0; i < 3; i++) { + const a = intersections[i]; + const b = intersections[(i + 1) % 3]; + if (a === null || b === null) continue; + baseSlice.lines.push(new Line3(a.clone(), b.clone())); + return; + } + } + }); + + baseSlices.push(baseSlice); + } + + return baseSlices; +} + +/** + * Creates shell rings from the base slice + * + * Consumes the base slices + */ +function createShellRings(baseSlice: BaseSlice): ShellRing[] { + const shellRings: ShellRing[] = []; + + let left: Vector3; + let right: Vector3; + + while (baseSlice.lines.length > 0) { + const start = baseSlice.lines.pop()!; + const shellRing: ShellRing = { + plane: baseSlice.plane, + points: [start.start, start.end] + }; + left = shellRing.points[0]; + right = shellRing.points[1]; + + // This should use a linked list ideally, but whatever + while (!approxEquals(left, right)) { + for (let i = 0; i < baseSlice.lines.length; i++) { + const line = baseSlice.lines[i]; + if (approxEquals(line.start, right)) { + shellRing.points.push(line.start, line.end); + right = line.end; + baseSlice.lines.splice(i, 1); + break; + } else if (approxEquals(line.end, right)) { + shellRing.points.push(line.end, line.start); + right = line.start; + baseSlice.lines.splice(i, 1); + break; + } + } + } + + shellRings.push(shellRing); + } + return shellRings; +} + +/** + * Creates standard slices from the geometry, excluding surfaces. + */ +export function slice(options: SlicerOptions, surfaceTriangles: boolean[]) { + const shellRings = createBaseSlices(options, surfaceTriangles).map(createShellRings); +} diff --git a/src/lib/slicer/util/equals.ts b/src/lib/slicer/util/equals.ts new file mode 100644 index 0000000..86cb091 --- /dev/null +++ b/src/lib/slicer/util/equals.ts @@ -0,0 +1,10 @@ +import type { Vector3 } from 'three'; + +/** + * Check if two vectors are approximately equal. + */ +export function approxEquals(a: Vector3, b: Vector3, epsilon = Number.EPSILON) { + return ( + Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon && Math.abs(a.z - b.z) < epsilon + ); +} diff --git a/src/lib/slicer/worker.ts b/src/lib/slicer/worker.ts index 0e3bbf3..c6ca2e3 100644 --- a/src/lib/slicer/worker.ts +++ b/src/lib/slicer/worker.ts @@ -16,237 +16,35 @@ import { type ProgressMessage, type WorkerEvent } from './worker-data'; +import init, { slice } from 'bampy'; -addEventListener('message', (event: MessageEvent) => { +addEventListener('message', async (event: MessageEvent) => { if (event.data.type === 'slice') { - slice(event.data.data); + const geometry = new BufferGeometryLoader().parse(event.data.data.stl); + if (geometry.index !== null) { + geometry.toNonIndexed(); + } + await init(); + slice( + geometry.attributes.position.array as Float32Array, + geometry.attributes.normal.array as Float32Array, + event.data.data.layerHeight + ); } }); -function slice({ +async function todo({ stl, bedNormal: bedNormalArray, maxNonPlanarAngle, tolerance, layerHeight }: SliceArguments) { + greet(); self.postMessage({ type: 'progress', percent: 0, layer: 0 } satisfies ProgressMessage); - const bedNormal = new Vector3(...bedNormalArray); - const geometry = new BufferGeometryLoader().parse(stl); - const bvh = new MeshBVH(geometry); - const positions = geometry.getAttribute('position'); - const normals = geometry.getAttribute('normal'); - const index = geometry.index!; - const qualifyingTriangles = Array.from({ length: index.count / 3 }, () => false); - let qualifyingTrianglesCount = 0; - const triangle = new ExtendedTriangle(); - const normal = new Vector3(); - for (let i = 0; i < index.count / 3; i++) { - triangle.setFromAttributeAndIndices( - positions, - index.array[i * 3], - index.array[i * 3 + 1], - index.array[i * 3 + 2] - ); - triangle.getNormal(normal); - const angle = normal.angleTo(bedNormal); - // TODO: bottom layers - if (angle < maxNonPlanarAngle) { - qualifyingTriangles[i] = true; - qualifyingTrianglesCount++; - } - } - const includedTriangles = [...qualifyingTriangles]; - const includedTrianglesCount = qualifyingTrianglesCount; + // TODO - const surfaces: number[][] = []; - while (qualifyingTrianglesCount > 0) { - const faceIndex = qualifyingTriangles.findIndex((it) => it); - qualifyingTriangles[faceIndex] = false; - qualifyingTrianglesCount--; - const surface = [faceIndex]; - let cursor = 0; - while (cursor < surface.length) { - triangle.setFromAttributeAndIndices( - positions, - index.array[surface[cursor] * 3], - index.array[surface[cursor] * 3 + 1], - index.array[surface[cursor] * 3 + 2] - ); - - bvh.shapecast({ - intersectsBounds(box, _isLeaf, _score, _depth, _nodeIndex) { - return triangle.intersectsBox(box); - }, - intersectsTriangle(target, triangleIndex, _contained, _depth) { - if ( - qualifyingTriangles[triangleIndex] && - target.distanceToTriangle(triangle) < tolerance - ) { - qualifyingTriangles[triangleIndex] = false; - qualifyingTrianglesCount--; - surface.push(triangleIndex); - } - } - }); - - cursor++; - } - surfaces.push(surface); - } - - const nonPlanarSurfaces = surfaces.map((surface) => { - const geometry = new BufferGeometry(); - geometry.setAttribute('position', positions); - geometry.setAttribute('normal', normals); - const indices: number[] = Array.from({ length: surface.length * 3 }); - for (let i = 0; i < surface.length; i++) { - const pos = surface[i] * 3; - indices[i * 3] = index.array[pos]; - indices[i * 3 + 1] = index.array[pos + 1]; - indices[i * 3 + 2] = index.array[pos + 2]; - } - geometry.setIndex(indices); - const bvh = new MeshBVH(geometry); - geometry.boundsTree = bvh; - return bvh; - }); - const activeNonPlanarSurfaces: [number, MeshBVH][] = []; - const consumedNonPlanarSurfaces = nonPlanarSurfaces.map(() => false); - const withheld: Array< - | { type: LayerType.Line; geometry: number[] } - | { type: LayerType.Surface; id: [number, MeshBVH] } - >[] = nonPlanarSurfaces.map(() => [{ type: LayerType.Line, geometry: [] }]); - const blacklist = Array.from({ length: index.count / 3 }).map(() => false); - - const line = new Line3(); - const targetVector1 = new Vector3(); - const targetVector2 = new Vector3(); - const targetVector3 = new Vector3(); - const hit1: HitPointInfo = { point: new Vector3(), distance: 0, faceIndex: 0 }; - const hit2: HitPointInfo = { point: new Vector3(), distance: 0, faceIndex: 0 }; - const layerPlane = new Plane(); - function deactivateSurface(surface: MeshBVH, index: number) { - self.postMessage({ - type: 'layer', - data: { type: LayerType.Surface, geometry: surface.geometry.toJSON() } - } satisfies LayerMessage); - for (const thing of withheld[index]) { - if (thing.type === LayerType.Line) { - if (thing.geometry.length === 0) continue; - const additionalGeometry = new BufferGeometry(); - additionalGeometry.setAttribute('position', new Float32BufferAttribute(thing.geometry, 3)); - self.postMessage({ - type: 'layer', - data: { type: LayerType.Line, geometry: additionalGeometry.toJSON() } - }); - } else if (thing.type === LayerType.Surface) { - deactivateSurface(thing.id[1], thing.id[0]); - } - } - delete withheld[index]; - } - for (let layer = 0; layer < geometry.boundingBox!.max.z; layer += layerHeight) { - layerPlane.set(bedNormal, -layer); - const layerGeometry = new BufferGeometry(); - const positions: number[] = []; - for (let i = 0; i < nonPlanarSurfaces.length; i++) { - if (consumedNonPlanarSurfaces[i]) continue; - if (layer >= nonPlanarSurfaces[i].geometry.boundingBox!.min.z) { - consumedNonPlanarSurfaces[i] = true; - activeNonPlanarSurfaces.push([i, nonPlanarSurfaces[i]]); - } - } - deactivate: for (let i = 0; i < activeNonPlanarSurfaces.length; i++) { - const [index, surface] = activeNonPlanarSurfaces[i]; - if (layer > surface.geometry.boundingBox!.max.z) { - activeNonPlanarSurfaces.splice(i, 1); - i--; - - for (const [activeIndex, active] of activeNonPlanarSurfaces) { - if (activeIndex === index) continue; - const hit = active.closestPointToGeometry(surface.geometry, new Matrix4(), hit1, hit2); - if ( - hit && - hit1.point.z < hit2.point.z && - Math.abs(Math.PI / 2 - hit1.point.clone().sub(hit2.point).angleTo(bedNormal)) > - maxNonPlanarAngle - ) { - withheld[activeIndex].push({ type: LayerType.Surface, id: [index, surface] }); - withheld[activeIndex].push({ type: LayerType.Line, geometry: [] }); - continue deactivate; - } - } - deactivateSurface(surface, index); - } - withheld[index]?.push({ type: LayerType.Line, geometry: [] }); - } - - bvh.shapecast({ - intersectsBounds(box, _isLeaf, _score, _depth, _nodeIndex) { - return layerPlane.intersectsBox(box); - }, - intersectsTriangle(target, triangleIndex, _contained, _depth) { - if (includedTriangles[triangleIndex] || blacklist[triangleIndex]) return; - function intersect(a: Vector3, b: Vector3, targetVector: Vector3) { - line.set(a, b); - return layerPlane.intersectLine(line, targetVector); - } - const a = intersect(target.a, target.b, targetVector1); - const b = intersect(target.b, target.c, targetVector2); - const c = intersect(target.c, target.a, targetVector3); - - function add(a: Vector3, b: Vector3) { - for (let i = 0; i < activeNonPlanarSurfaces.length; i++) { - const [index, surface] = activeNonPlanarSurfaces[i]; - const withheldLayer = withheld[index].at(-1)!; - if (withheldLayer.type === LayerType.Surface) throw new Error('Unexpected surface'); - const h1 = surface.closestPointToPoint(a); - if ( - h1 && - h1.point.z < a.z && - Math.abs(Math.PI / 2 - h1.point.clone().sub(a).angleTo(bedNormal)) > maxNonPlanarAngle - ) { - withheldLayer.geometry.push(a.x, a.y, a.z, b.x, b.y, b.z); - return; - } - const h2 = surface.closestPointToPoint(b); - if ( - h2 && - h2.point.z < b.z && - Math.abs(Math.PI / 2 - h2.point.clone().sub(b).angleTo(bedNormal)) > maxNonPlanarAngle - ) { - withheldLayer.geometry.push(a.x, a.y, a.z, b.x, b.y, b.z); - return; - } - } - positions.push(a.x, a.y, a.z, b.x, b.y, b.z); - } - - if (a && b) { - add(a, b); - } else if (b && c) { - add(b, c); - } else if (c && a) { - add(c, a); - } - } - }); - layerGeometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); - self.postMessage({ - type: 'layer', - data: { type: LayerType.Line, geometry: layerGeometry.toJSON() } - } satisfies LayerMessage); - self.postMessage({ - type: 'progress', - percent: layer / geometry.boundingBox!.max.z, - layer: Math.round(layer / layerHeight) - } satisfies ProgressMessage); - } - for (const [index, surface] of activeNonPlanarSurfaces) { - deactivateSurface(surface, index); - } self.postMessage({ type: 'progress', layer: Math.round(geometry.boundingBox!.max.z / layerHeight) diff --git a/vite.config.ts b/vite.config.ts index 3e1aac0..6aeef1d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,10 @@ import { sveltekit } from '@sveltejs/kit/vite'; import wasm from 'vite-plugin-wasm'; +import wasmPack from 'vite-plugin-wasm-pack'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit(), wasm()], + plugins: [sveltekit(), wasmPack('./bampy'), wasm()], ssr: { noExternal: ['three'] }