@ -0,0 +1,2 @@ | |||
/target | |||
Cargo.lock |
@ -0,0 +1,22 @@ | |||
[package] | |||
name = "optocut-web" | |||
version = "0.1.0" | |||
authors = ["Stephen <webmaster@scd31.com>"] | |||
edition = "2018" | |||
[lib] | |||
crate-type = ["cdylib", "rlib"] | |||
[profile.release] | |||
panic = 'abort' | |||
codegen-units = 1 | |||
opt-level = 'z' | |||
lto = true | |||
[dependencies] | |||
yew = "0.17" | |||
wasm-bindgen = "0.2.67" | |||
wee_alloc="0.4.5" | |||
log = "0.4" | |||
wasm-logger = "0.2.0" |
@ -0,0 +1,4 @@ | |||
build: | |||
mkdir -p static | |||
cp -r public/* static/ | |||
wasm-pack build --target web --out-name wasm --out-dir ./static |
@ -0,0 +1,16 @@ | |||
<!doctype html> | |||
<html lang="en"> | |||
<head> | |||
<link href="/bootstrap/css/bootstrap.min.css" rel="stylesheet" /> | |||
<link href="/style.css" rel="stylesheet" /> | |||
<script src="/bootstrap/js/bootstrap.min.js"></script> | |||
<meta charset="utf-8"> | |||
<title>Optocut</title> | |||
<script type="module"> | |||
import init from "./wasm.js" | |||
init() | |||
</script> | |||
</head> | |||
<body> | |||
</body> | |||
</html> |
@ -0,0 +1,23 @@ | |||
h1 { | |||
width: 100%; | |||
padding: 8px; | |||
} | |||
input[type=number] { | |||
width: 80px; | |||
} | |||
.wood_in { | |||
padding-left: 8px; | |||
} | |||
.wood { | |||
border: 1px solid black; | |||
border-radius: 25px; | |||
margin-bottom: 8px; | |||
padding: 8px; | |||
} | |||
.right { | |||
float: right; | |||
} |
@ -0,0 +1,2 @@ | |||
tab_spaces = 4 | |||
hard_tabs = true |
@ -0,0 +1,140 @@ | |||
#[derive(Debug, Clone)] | |||
pub struct Wood1D<'a> { | |||
name: &'a str, | |||
remaining: f64, // mm | |||
pieces: Vec<&'a Req1D<'a>>, | |||
cost: u32, // cents | |||
} | |||
impl<'a> Wood1D<'a> { | |||
pub fn new(name: &'a str, length: f64, cost: u32) -> Self { | |||
Self { | |||
name, | |||
remaining: length + 0.001, // To account for floating point garbage | |||
cost, | |||
pieces: Vec::new(), | |||
} | |||
} | |||
fn try_cut(&mut self, req: &'a Req1D<'a>, blade_width: f64) -> bool { | |||
if req.length + blade_width <= self.remaining { | |||
self.remaining -= req.length + blade_width; | |||
self.pieces.push(req); | |||
true | |||
} else { | |||
false | |||
} | |||
} | |||
fn cut(&mut self, req: &'a Req1D<'a>, blade_width: f64) { | |||
assert!(self.try_cut(req, blade_width)); | |||
} | |||
} | |||
#[derive(Debug, Clone)] | |||
pub struct Req1D<'a> { | |||
name: &'a str, | |||
length: f64, // cm | |||
} | |||
impl<'a> Req1D<'a> { | |||
pub fn new(name: &'a str, length: f64) -> Self { | |||
Self { name, length } | |||
} | |||
} | |||
#[derive(Debug)] | |||
pub struct CutRow { | |||
pub name: String, | |||
pub remaining: f64, // mm | |||
pub pieces: Vec<String>, | |||
pub cost: u32, // cents | |||
} | |||
#[derive(Debug, Clone)] | |||
struct State<'a> { | |||
lumber: Vec<Wood1D<'a>>, | |||
remaining_pieces: Vec<&'a Req1D<'a>>, // cm | |||
} | |||
impl<'a> State<'a> { | |||
fn cost(&self) -> u32 { | |||
self.lumber.iter().map(|wood| wood.cost).sum() | |||
} | |||
} | |||
pub fn find_best_cuts<'a>( | |||
available: Vec<Wood1D<'a>>, | |||
required: Vec<Req1D<'a>>, | |||
blade_width: f64, | |||
) -> Result<Vec<CutRow>, &'static str> { | |||
// Check that all in required are <= length of available | |||
let avail_max = available | |||
.iter() | |||
.map(|x| x.remaining) | |||
.fold(f64::NAN, f64::max); | |||
for r in &required { | |||
if r.length > avail_max { | |||
return Err("Error: One of your required pieces is larger than all available pieces."); | |||
} | |||
} | |||
let start_state = State { | |||
lumber: Vec::new(), | |||
remaining_pieces: required.iter().collect(), | |||
}; | |||
let mut best_cost = u32::MAX; | |||
let mut best_state = start_state.clone(); | |||
let mut frontier = Vec::new(); | |||
frontier.push(start_state); | |||
while let Some(mut cur) = frontier.pop() { | |||
if cur.cost() >= best_cost { | |||
continue; | |||
} | |||
match cur.remaining_pieces.pop() { | |||
Some(next_piece) => { | |||
// All combinations of adding onto existing wood | |||
let mut found_space = false; | |||
for (i, wood) in cur.lumber.iter().enumerate() { | |||
if wood.remaining > next_piece.length { | |||
let mut new_state = cur.clone(); | |||
new_state.lumber[i].cut(&next_piece, blade_width); | |||
frontier.push(new_state); | |||
found_space = true; | |||
} | |||
} | |||
// All combinations of adding onto new wood | |||
if !found_space { | |||
for new_wood in &available { | |||
if new_wood.remaining > next_piece.length | |||
&& new_wood.cost + cur.cost() < best_cost | |||
{ | |||
let mut new_state = cur.clone(); | |||
let mut new_wood = new_wood.to_owned(); | |||
new_wood.cut(&next_piece, blade_width); | |||
new_state.lumber.push(new_wood); | |||
frontier.push(new_state); | |||
} | |||
} | |||
} | |||
} | |||
None => { | |||
best_cost = cur.cost(); | |||
best_state = cur.clone(); | |||
} | |||
} | |||
} | |||
Ok(best_state | |||
.lumber | |||
.iter() | |||
.map(|wood| CutRow { | |||
name: wood.name.to_string(), | |||
cost: wood.cost, | |||
pieces: wood.pieces.iter().map(|x| x.name.to_string()).collect(), | |||
remaining: wood.remaining, | |||
}) | |||
.collect()) | |||
} |
@ -0,0 +1,3 @@ | |||
pub mod available; | |||
pub mod cutlist; | |||
pub mod wood; |
@ -0,0 +1,95 @@ | |||
use crate::components::wood::WoodComponent; | |||
use yew::prelude::*; | |||
use yew::Properties; | |||
pub enum Msg { | |||
NameChange(String), | |||
LengthChange(f64), | |||
CostChange(String), | |||
} | |||
#[derive(Debug, Clone, PartialEq, Properties)] | |||
pub struct AvailableProperties { | |||
// TODO need metric/imperial switch | |||
pub metric: bool, | |||
pub name: String, // TODO we should auto-generate the names based on the given measurements (?) | |||
pub name_callback: Callback<String>, | |||
pub length: f64, // mm | |||
pub length_callback: Callback<f64>, | |||
pub cost: u32, // cents | |||
pub cost_callback: Callback<u32>, | |||
} | |||
pub struct AvailableComponent { | |||
link: ComponentLink<Self>, | |||
props: AvailableProperties, | |||
} | |||
impl Component for AvailableComponent { | |||
type Message = Msg; | |||
type Properties = AvailableProperties; | |||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { | |||
Self { link, props } | |||
} | |||
fn update(&mut self, msg: Self::Message) -> ShouldRender { | |||
let mut change = false; | |||
match msg { | |||
Msg::NameChange(s) => { | |||
self.props.name = s.clone(); | |||
self.props.name_callback.emit(s); | |||
change = true; | |||
} | |||
Msg::LengthChange(v) => { | |||
self.props.length = v; | |||
self.props.length_callback.emit(self.props.length); | |||
change = true; | |||
} | |||
Msg::CostChange(s) => { | |||
if let Ok(cost) = s.parse::<f64>() { | |||
self.props.cost = (cost * 100.0) as u32; | |||
self.props.cost_callback.emit(self.props.cost); | |||
change = true; | |||
} | |||
} | |||
} | |||
change | |||
} | |||
fn change(&mut self, props: Self::Properties) -> ShouldRender { | |||
if self.props != props { | |||
self.props = props; | |||
true | |||
} else { | |||
false | |||
} | |||
} | |||
fn view(&self) -> Html { | |||
html! { | |||
<> | |||
<WoodComponent | |||
name=self.props.name.clone() | |||
name_callback=self.link.callback(Msg::NameChange) | |||
length=self.props.length | |||
length_callback=self.link.callback(Msg::LengthChange)/> | |||
// price | |||
<tr> | |||
<td> | |||
{ "Cost: " } | |||
</td> | |||
<td> | |||
{ "$" } | |||
<input type="number" min="0" step="0.01" value={self.props.cost as f64 / 100.0} | |||
oninput=self.link.callback(|e: InputData| Msg::CostChange(e.value)) /> | |||
</td> | |||
</tr> | |||
</> | |||
} | |||
} | |||
} |
@ -0,0 +1,60 @@ | |||
use yew::prelude::*; | |||
#[derive(Debug, Clone, PartialEq, Properties)] | |||
pub struct CutlistProperties { | |||
pub name: String, | |||
pub cost: u32, | |||
pub cuts: Vec<String>, | |||
pub remaining: f64, // mm | |||
} | |||
pub struct CutlistComponent { | |||
link: ComponentLink<Self>, | |||
props: CutlistProperties, | |||
} | |||
impl Component for CutlistComponent { | |||
type Message = (); | |||
type Properties = CutlistProperties; | |||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { | |||
Self { link, props } | |||
} | |||
fn update(&mut self, _: Self::Message) -> ShouldRender { | |||
false | |||
} | |||
fn change(&mut self, props: Self::Properties) -> ShouldRender { | |||
if self.props != props { | |||
self.props = props; | |||
true | |||
} else { | |||
false | |||
} | |||
} | |||
fn view(&self) -> Html { | |||
html! { | |||
<div class="wood"> | |||
<b>{&self.props.name}</b> | |||
<div class="right"> | |||
{ "$" } | |||
{ format!("{:.2}", self.props.cost as f64 / 100.0) } | |||
<br /> | |||
{ format!("{:.2}", self.props.remaining) } | |||
{ " mm leftover" } | |||
</div> | |||
<ul> | |||
{ | |||
for self.props.cuts.iter().map(|cut| { | |||
html! { | |||
<li>{cut}</li> | |||
} | |||
}) | |||
} | |||
</ul> | |||
</div> | |||
} | |||
} | |||
} |
@ -0,0 +1,139 @@ | |||
use yew::prelude::*; | |||
use yew::Properties; | |||
pub enum Msg { | |||
NameChange(String), | |||
FeetChange(String), | |||
InchChange(String), | |||
MmChange(String), | |||
} | |||
#[derive(Debug, Clone, PartialEq, Properties)] | |||
pub struct WoodProperties { | |||
pub length: f64, // mm | |||
pub length_callback: Callback<f64>, | |||
pub name: String, | |||
pub name_callback: Callback<String>, | |||
} | |||
pub struct WoodComponent { | |||
link: ComponentLink<Self>, | |||
props: WoodProperties, | |||
ft: f64, | |||
inches: f64, | |||
mm: f64, | |||
} | |||
impl Component for WoodComponent { | |||
type Message = Msg; | |||
type Properties = WoodProperties; | |||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { | |||
Self { | |||
link, | |||
props, | |||
ft: 0.0, | |||
inches: 0.0, | |||
mm: 0.0, | |||
} | |||
} | |||
fn update(&mut self, msg: Self::Message) -> ShouldRender { | |||
let mut change = false; | |||
match msg { | |||
Msg::NameChange(s) => { | |||
self.props.name = s.clone(); | |||
self.props.name_callback.emit(s); | |||
change = true; | |||
} | |||
Msg::FeetChange(s) => { | |||
if let Ok(ft) = s.parse() { | |||
self.ft = ft; | |||
change = true; | |||
} | |||
} | |||
Msg::InchChange(s) => { | |||
if let Ok(inches) = s.parse() { | |||
self.inches = inches; | |||
change = true; | |||
} | |||
} | |||
Msg::MmChange(s) => { | |||
if let Ok(mm) = s.parse() { | |||
self.mm = mm; | |||
change = true; | |||
} | |||
} | |||
} | |||
if change { | |||
self.props.length = self.mm + self.inches * 25.4 + self.ft * 304.8; | |||
self.props.length_callback.emit(self.props.length); | |||
} | |||
change | |||
} | |||
fn change(&mut self, props: Self::Properties) -> ShouldRender { | |||
if self.props != props { | |||
self.props = props; | |||
true | |||
} else { | |||
false | |||
} | |||
} | |||
fn view(&self) -> Html { | |||
html! { | |||
<> | |||
// name | |||
<tr> | |||
<td> | |||
{ "Name: " } | |||
</td> | |||
<td> | |||
<input type="text" value={&self.props.name} | |||
oninput=self.link.callback(|e: InputData| Msg::NameChange(e.value)) | |||
/> | |||
</td> | |||
</tr> | |||
// feet | |||
<tr> | |||
<td> | |||
{ "Feet: " } | |||
</td> | |||
<td> | |||
<input type="number" min="0" step="1" value={self.ft} | |||
oninput=self.link.callback(|e: InputData| Msg::FeetChange(e.value)) /> | |||
</td> | |||
</tr> | |||
//inches | |||
<tr> | |||
<td> | |||
{ "Inches: " } | |||
</td> | |||
<td> | |||
<input type="number" min="0" step="1" value={self.inches} | |||
oninput=self.link.callback(|e: InputData| Msg::InchChange(e.value)) /> | |||
</td> | |||
</tr> | |||
// mm | |||
<tr> | |||
<td> | |||
{ "Millimeters: " } | |||
</td> | |||
<td> | |||
<input type="number" min="0" step="0.1" value={self.mm} | |||
oninput=self.link.callback(|e: InputData| Msg::MmChange(e.value)) /> | |||
</td> | |||
</tr> | |||
</> | |||
} | |||
} | |||
} |
@ -0,0 +1,301 @@ | |||
#![recursion_limit = "1024"] | |||
#[global_allocator] | |||
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; | |||
use wasm_bindgen::prelude::*; | |||
use yew::prelude::*; | |||
use yew::services::DialogService; | |||
mod components; | |||
use components::available::AvailableComponent; | |||
use components::cutlist::CutlistComponent; | |||
use components::wood::WoodComponent; | |||
mod calculator; | |||
use calculator::{find_best_cuts, CutRow, Req1D, Wood1D}; | |||
struct AvailableRow { | |||
name: String, | |||
length: f64, // mm | |||
cost: u32, // cents | |||
deleted: bool, //tombstones are kind of hacky in this case. TODO fix | |||
} | |||
impl AvailableRow { | |||
fn new() -> Self { | |||
Self { | |||
name: "".to_string(), | |||
length: 0.0, | |||
cost: 0, | |||
deleted: false, | |||
} | |||
} | |||
fn name(&self) -> String { | |||
if self.name.is_empty() { | |||
format!("[Unnamed] {} mm", self.length) | |||
} else { | |||
self.name.clone() | |||
} | |||
} | |||
} | |||
struct RequiredRow { | |||
name: String, | |||
length: f64, | |||
amount: usize, | |||
deleted: bool, | |||
} | |||
impl RequiredRow { | |||
fn new() -> Self { | |||
Self { | |||
name: "".to_string(), | |||
length: 0.0, | |||
amount: 1, | |||
deleted: false, | |||
} | |||
} | |||
fn name(&self) -> String { | |||
if self.name.is_empty() { | |||
format!("[Unnamed] {} mm", self.length) | |||
} else { | |||
self.name.clone() | |||
} | |||
} | |||
} | |||
enum Msg { | |||
AddAvailable, | |||
ChangeAvailableName(usize, String), | |||
ChangeAvailableLength(usize, f64), | |||
ChangeAvailableCost(usize, u32), | |||
DeleteAvailable(usize), | |||
AddRequired, | |||
ChangeRequiredName(usize, String), | |||
ChangeRequiredLength(usize, f64), | |||
ChangeRequiredQuantity(usize, String), | |||
DeleteRequired(usize), | |||
Calculate, | |||
} | |||
struct Model { | |||
link: ComponentLink<Self>, | |||
available: Vec<AvailableRow>, | |||
required: Vec<RequiredRow>, | |||
total_cost: u32, | |||
output: Vec<CutRow>, | |||
} | |||
impl Component for Model { | |||
type Message = Msg; | |||
type Properties = (); | |||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { | |||
Self { | |||
link, | |||
available: vec![], | |||
required: vec![], | |||
total_cost: 0, | |||
output: vec![], | |||
} | |||
} | |||
fn update(&mut self, msg: Self::Message) -> ShouldRender { | |||
match msg { | |||
Msg::AddAvailable => self.available.push(AvailableRow::new()), | |||
Msg::ChangeAvailableName(id, name) => { | |||
self.available[id].name = name; | |||
} | |||
Msg::ChangeAvailableLength(id, len) => { | |||
self.available[id].length = len; | |||
} | |||
Msg::ChangeAvailableCost(id, cost) => { | |||
self.available[id].cost = cost; | |||
} | |||
Msg::DeleteAvailable(id) => { | |||
self.available[id].deleted = true; | |||
} | |||
Msg::AddRequired => { | |||
self.required.push(RequiredRow::new()); | |||
} | |||
Msg::ChangeRequiredName(id, name) => { | |||
self.required[id].name = name; | |||
} | |||
Msg::ChangeRequiredLength(id, len) => { | |||
self.required[id].length = len; | |||
} | |||
Msg::ChangeRequiredQuantity(id, s) => { | |||
if let Ok(x) = s.parse() { | |||
self.required[id].amount = x; | |||
} | |||
} | |||
Msg::DeleteRequired(id) => { | |||
self.required[id].deleted = true; | |||
} | |||
Msg::Calculate => { | |||
// convert available/required wood to the expected types | |||
let available_names: Vec<String> = | |||
self.available.iter().map(|x| x.name()).collect(); | |||
let available: Vec<Wood1D> = self | |||
.available | |||
.iter() | |||
.enumerate() | |||
.filter(|(_, x)| !x.deleted) | |||
.map(|(i, x)| Wood1D::new(&available_names[i], x.length, x.cost)) | |||
.collect(); | |||
let required_names: Vec<String> = self.required.iter().map(|x| x.name()).collect(); | |||
let mut required = Vec::new(); | |||
for (i, req) in self.required.iter().enumerate().filter(|(_, x)| !x.deleted) { | |||
for _ in 0..req.amount { | |||
required.push(Req1D::new(&required_names[i], req.length)); | |||
} | |||
} | |||
// Do the calculations | |||
match find_best_cuts(available, required, 0.0) { | |||
Ok(output) => { | |||
self.total_cost = output.iter().map(|x| x.cost).fold(0, |acc, x| acc + x); | |||
self.output = output; | |||
} | |||
Err(e) => { | |||
// alert | |||
// TODO snackbar/toast? | |||
DialogService::alert(e); | |||
} | |||
} | |||
} | |||
} | |||
true | |||
} | |||
fn change(&mut self, _props: Self::Properties) -> ShouldRender { | |||
// Should only return "true" if new properties are different to | |||
// previously received properties. | |||
// This component has no properties so we will always return "false". | |||
false | |||
} | |||
fn view(&self) -> Html { | |||
html! { | |||
<div> | |||
<h1 class="bg-primary text-white">{ "Optocut" }</h1> | |||
<div class="container"> | |||
<div class="row"> | |||
<div class="col-sm"> | |||
<h2> | |||
{ "Available lumber" } | |||
<button onclick=self.link.callback(|_| Msg::AddAvailable)>{ "+" }</button> | |||
</h2> | |||
{ | |||
for self.available.iter().enumerate().filter(|(idx, wood)| !wood.deleted).map(|(idx, wood)| { | |||
html! { | |||
<div class="wood"> | |||
<table> | |||
<AvailableComponent | |||
length=wood.length metric=false | |||
length_callback=self.link.callback(move |v| Msg::ChangeAvailableLength(idx, v)) | |||
cost=wood.cost | |||
cost_callback=self.link.callback(move |v| Msg::ChangeAvailableCost(idx, v)) | |||
name=wood.name.clone() | |||
name_callback=self.link.callback(move |v| Msg::ChangeAvailableName(idx, v)) | |||
/> | |||
<tr> | |||
<td> | |||
<button class="btn btn-danger" | |||
onclick=self.link.callback(move |_| Msg::DeleteAvailable(idx)) > | |||
{ "Delete" } | |||
</button> | |||
</td> | |||
</tr> | |||
</table> | |||
</div> | |||
} | |||
}) | |||
} | |||
</div> | |||
<div class="col-sm"> | |||
<h2> | |||
{ "Required lengths" } | |||
<button onclick=self.link.callback(|_| Msg::AddRequired)>{ "+" }</button> | |||
</h2> | |||
{ | |||
for self.required.iter().enumerate().filter(|(idx, wood)| !wood.deleted).map(|(idx, wood)| { | |||
html! { | |||
<div class="wood"> | |||
<table> | |||
<WoodComponent | |||
length=wood.length | |||
length_callback=self.link.callback(move |v| Msg::ChangeRequiredLength(idx, v)) | |||
name=wood.name.clone() | |||
name_callback=self.link.callback(move |v| Msg::ChangeRequiredName(idx, v)) | |||
/> | |||
<tr> | |||
<td> | |||
{ "Quantity: " } | |||
</td> | |||
<td> | |||
<input type="number" min="0" step="1" value={wood.amount} | |||
oninput=self.link.callback(move |e: InputData| Msg::ChangeRequiredQuantity(idx, e.value)) /> | |||
</td> | |||
</tr> | |||
<tr> | |||
<td> | |||
<button class="btn btn-danger" | |||
onclick=self.link.callback(move |_| Msg::DeleteRequired(idx)) >{ "Delete" }</button> | |||
</td> | |||
</tr> | |||
</table> | |||
</div> | |||
} | |||
}) | |||
} | |||
</div> | |||
<div class="col-sm"> | |||
<button onclick=self.link.callback(|_| Msg::Calculate)>{ "Calculate" }</button> | |||
<div class="total-price"> | |||
{ "Total cost: $" } | |||
{ format!("{:.2}", self.total_cost as f64 / 100.0) } | |||
{ | |||
for self.output.iter().map(|row| { | |||
html! { | |||
<CutlistComponent | |||
name=&row.name | |||
cost=row.cost | |||
cuts=&row.pieces | |||
remaining=row.remaining | |||
/> | |||
} | |||
}) | |||
} | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
} | |||
} | |||
} | |||
#[wasm_bindgen(start)] | |||
pub fn run_app() { | |||
wasm_logger::init(wasm_logger::Config::default()); | |||
App::<Model>::new().mount_to_body(); | |||
} |