Browse Source

Very basic prototype

master
Stephen 1 month ago
commit
d78100762e
12 changed files with 807 additions and 0 deletions
  1. +2
    -0
      .gitignore
  2. +22
    -0
      Cargo.toml
  3. +4
    -0
      Makefile
  4. +16
    -0
      public/index.html
  5. +23
    -0
      public/style.css
  6. +2
    -0
      rustfmt.toml
  7. +140
    -0
      src/calculator.rs
  8. +3
    -0
      src/components.rs
  9. +95
    -0
      src/components/available.rs
  10. +60
    -0
      src/components/cutlist.rs
  11. +139
    -0
      src/components/wood.rs
  12. +301
    -0
      src/lib.rs

+ 2
- 0
.gitignore View File

@ -0,0 +1,2 @@
/target
Cargo.lock

+ 22
- 0
Cargo.toml View File

@ -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"

+ 4
- 0
Makefile View File

@ -0,0 +1,4 @@
build:
mkdir -p static
cp -r public/* static/
wasm-pack build --target web --out-name wasm --out-dir ./static

+ 16
- 0
public/index.html View File

@ -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>

+ 23
- 0
public/style.css View File

@ -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;
}

+ 2
- 0
rustfmt.toml View File

@ -0,0 +1,2 @@
tab_spaces = 4
hard_tabs = true

+ 140
- 0
src/calculator.rs View File

@ -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())
}

+ 3
- 0
src/components.rs View File

@ -0,0 +1,3 @@
pub mod available;
pub mod cutlist;
pub mod wood;

+ 95
- 0
src/components/available.rs View File

@ -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>
</>
}
}
}

+ 60
- 0
src/components/cutlist.rs View File

@ -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>
}
}
}

+ 139
- 0
src/components/wood.rs View File

@ -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>
</>
}
}
}

+ 301
- 0
src/lib.rs View File

@ -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();
}

Loading…
Cancel
Save