Last active
May 19, 2023 17:58
-
-
Save steamraven/c5536d490468e8106caf8f21abd0a0f6 to your computer and use it in GitHub Desktop.
Table Creator for StageTop
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
MIT License | |
Copyright (c) 2023 Matthew Hawn | |
Permission is hereby granted, free of charge, to any person obtaining a copy | |
of this software and associated documentation files (the "Software"), to deal | |
in the Software without restriction, including without limitation the rights | |
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all | |
copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
SOFTWARE. | |
""" | |
# pyright: reportUnusedFunction=false | |
from dataclasses import dataclass | |
from pathlib import Path | |
import types | |
from typing import Any, Callable, Iterator, SupportsInt, cast | |
import pandas | |
from htmltools import css | |
from pandas import NA, DataFrame, isna, notna # type: ignore pandas stub is incomplete | |
from pandas._libs.missing import NAType | |
from py2vega.functions.string import pad | |
from shinywidgets import output_widget, reactive_read, register_widget, render_widget | |
from shiny import App, Outputs, Session, reactive, render, ui, Inputs | |
# Reactivity requires copying dataframes instead of in-place updates | |
pandas.options.mode.copy_on_write = True | |
########################################################## | |
# tunables | |
default_datafile = Path(__file__).parent / "stagetop.csv" | |
name_columns = ["Type", "Name", "Style", "Version"] # For creating the part name | |
# sorting main dataframe | |
sort_columns = [ | |
"has_qty", | |
"Type", | |
"Name", | |
"Style", | |
"PrintTime", | |
"Filament", | |
] | |
sort_order = [True if c != "has_qty" else False for c in sort_columns] | |
QtyCallable = Callable[[int, int, str, str, str], int] | |
# Quantity Calculation Formulas | |
qty_calculations: dict[str, QtyCallable] = { | |
"Frame": lambda w, h, rail, playtile, leg: w * h, # Simple | |
"FrameLock": lambda w, h, rail, playtile, leg: ( | |
((w - 1) * h) | |
+ (w * (h - 1)) # one to connect each frame | |
+ ( | |
(2 * w + 2 * h) if rail != "Lite" else 0 | |
) # for non-lite rail, one for each frame edge | |
), | |
"TileLock": lambda w, h, rail, playtile, leg: 4 * w * h, # 4 per faame | |
"4": lambda w, h, rail, playtile, leg: 4, # 4 corners to a rectangle | |
"Tile": lambda w, h, rail, playtile, leg: ( | |
(w - 1) * (h - 1) # Full tiles overlay internal frame joins | |
), | |
"TileHalf": lambda w, h, rail, playtile, leg: ( | |
2 * (w - 2) + 2 * (h - 2) # Half tiles fill in edges - corners | |
), | |
"Rail": lambda w, h, rail, playtile, leg: ( | |
2 * (w - 1) + 2 * (h - 1) # edges - corners | |
), | |
"LiteRailLock": lambda w, h, rail, playtile, leg: ( | |
4 # one lock / corner rail | |
+ 2 * (2 * (w - 1) + 2 * (h - 1)) # two per reg rail | |
+ ( | |
4 * (w - 1) * (h - 1) if leg == "Lite - Short" else 0 | |
) # four for each internal frame join if using locks as legs | |
), | |
} | |
# Style selection controls | |
@dataclass | |
class StyleDependency: | |
condition_type: str | |
condition_style: str | |
dependency_type: str | |
dependency_style: str | |
style_dependencies: list[StyleDependency] = [ | |
StyleDependency("Leg", "Lite - Short", "Rail", "Lite") | |
] | |
@dataclass | |
class StyleInput: | |
part_type: str | |
select_name: str | |
label: str | |
style_inputs = [ | |
StyleInput("Rail", "select_rail_style", "Rail Style:"), | |
StyleInput("Playtile", "select_playtile_style", "Playtile Style:"), | |
StyleInput("Leg", "select_leg_style", "Leg Style:"), | |
] | |
######################################################################## | |
# utility functions | |
# Print Time in CSV and displayed as HH:MM | |
# Stored in dataframe as just total minutes | |
# Vega is a subset of python used as expression language by DataGrid | |
def to_minutes(text: str | None) -> int | None: | |
if text is None or not text: | |
return None | |
split = text.split(":") | |
if len(split) != 2: | |
return None | |
return int(split[0]) * 60 + int(split[1]) | |
def format_minutes(min: float) -> str: | |
if isna(min): | |
return "" | |
min = int(min) | |
return str(min // 60) + ":" + str(min % 60).rjust(2, "0") | |
# must return truthy value | |
def format_minutes_vega(cell: Any): | |
if cell.value: | |
return str(int(cell.value / 60)) + ":" + pad(str(cell.value % 60), 2, "0", "left") | |
else: | |
return "-" | |
def to_int_nan(value: str | None) -> int | NAType: | |
if value is None or value == "": | |
return NA | |
else: | |
return int(value) | |
# Display "-" instead of NaN for blank numbers""" | |
# Must return truthy value | |
def format_empty_nan_vega(cell: Any): | |
return cell.value if cell.value else "-" | |
# Must pass datagrid module as it can only be imported within the server function | |
def dg_renderers(dg: types.ModuleType) -> dict[str, Any]: | |
"Datagrid renderers for time and integers" | |
time_renderer = dg.TextRenderer( | |
text_value=dg.Expr( | |
format_minutes_vega, | |
), | |
missing="", | |
) | |
nan_renderer = dg.TextRenderer(text_value=dg.Expr(format_empty_nan_vega)) | |
return { | |
"PrintTime": time_renderer, | |
"ExtPrinTime": time_renderer, | |
"Infill": nan_renderer, | |
"Filament": nan_renderer, | |
"ExtFilament": nan_renderer, | |
} | |
def set_required(tag: ui.Tag) -> ui.Tag: | |
"Set the required attr on input tags" | |
assert tag.name == "div" and len(tag.children) == 2 | |
for child_tag in tag.children: | |
if isinstance(child_tag, ui.Tag) and child_tag.name == "input": | |
child_tag.attrs["required"] = True | |
return tag | |
def check_style_dependencies(input: Inputs) -> str | None: | |
"Check style dependencies using table. Return None for no error, or error string" | |
table = {s.part_type: getattr(input, s.select_name)() for s in style_inputs} | |
for dep in style_dependencies: | |
if table[dep.condition_type] == dep.condition_style: | |
if table[dep.dependency_type] != dep.dependency_style: | |
return f'{dep.condition_type} style "{dep.condition_style}" requires {dep.dependency_type} style "{dep.dependency_style}"' | |
return None | |
def instr_row(instr: ui.TagChild, *args: ui.TagChild): | |
"Helper to renter an instruction row" | |
return ui.layout_sidebar( | |
ui.panel_sidebar(instr, width=3, style=css(height="100%")), | |
ui.panel_main(*args, width=9), | |
) | |
############################################################################################################# | |
# UI/Frontend | |
app_ui = ui.page_fluid( | |
ui.div( | |
ui.h2("Table Creator for the StageTop Modular, 3D Printed Table"), | |
ui.a( | |
"Kickstarter page", | |
href="https://www.kickstarter.com/projects/gutshotgames/stagetop-the-3d-printed-gaming-table", | |
), | |
ui.p("Copyright 2023 Matthew Hawn"), | |
ui.p("This project is independant and not affiliated with StageTop, GutShotGames, or MyMiniFactory "), | |
class_="text-center", | |
), | |
instr_row( | |
"Step 1: Select datafile or use default", | |
ui.input_file("file_base_datafile", "Data File:", placeholder="Select File"), | |
), | |
ui.hr(), | |
instr_row( | |
"Step 2: Generate a basic table", | |
ui.row( | |
set_required(ui.input_numeric("numeric_width", "Width:", 3)), | |
set_required(ui.input_numeric("numeric_height", "Height:", 4)), | |
), | |
ui.row(*(ui.input_select(s.select_name, s.label, []) for s in style_inputs)), | |
ui.row(ui.output_text("text_style_error")), | |
ui.row(ui.input_action_button("button_generate_table", "Generate Table")), | |
), | |
ui.hr(), | |
instr_row( | |
ui.div( | |
ui.p("Step 3: Review components"), | |
ui.p("You can sort and filter on any column"), | |
ui.p("To reset all values, regenerate a table above"), | |
), | |
ui.row( | |
ui.input_switch("switch_show_qty", "Only Show Qty > 0"), | |
ui.input_action_button( | |
"button_clear_time_filament", "Clear ALL time and filament", width="300px" | |
), | |
), | |
ui.row(output_widget("widget_grid")), | |
), | |
ui.hr(), | |
instr_row( | |
ui.div( | |
"Step 4: Update components", ui.HTML(" "), | |
ui.input_action_link("link_comp_more", "More Info"), | |
), | |
ui.row( | |
ui.div( | |
ui.row("Part Name"), | |
ui.row(ui.output_text("text_selection_name")), | |
class_="col", | |
), | |
ui.div( | |
ui.row("Qty"), | |
ui.row( | |
set_required( | |
ui.input_numeric("numeric_selection_qty", None, value=0, width="100px") | |
) | |
), | |
class_="col-auto", | |
), | |
ui.div( | |
ui.row("Print Time"), | |
ui.row(ui.input_text("numeric_selection_time", None, width="100px")), | |
class_="col-auto", | |
), | |
ui.div( | |
ui.row("Filament (g)"), | |
ui.row( | |
ui.input_numeric( | |
"numeric_selection_filament", None, value=0, width="100px" | |
), | |
), | |
class_="col-auto", | |
), | |
ui.div( | |
ui.row(ui.br()), | |
ui.row( | |
ui.input_action_button("button_selection_update", "Update", width="90px") | |
), | |
class_="col-auto", | |
), | |
), | |
), | |
ui.hr(), | |
instr_row( | |
"Step 5: Review totals", | |
ui.row(ui.output_text("text_totals_error")), | |
ui.row( | |
"Totals:", | |
output_widget("widget_totals"), | |
), | |
), | |
ui.hr(), | |
instr_row( | |
"Step 6: Save your data", | |
ui.row(ui.download_button("button_download_data", "Download data")), | |
), | |
title="Table Creator for StageTop", | |
) | |
################################################################################################## | |
# Backend | |
def server(input: Inputs, output: Outputs, session: Session): | |
# Reactive values | |
base_df: reactive.Value[Any | None] = reactive.Value(None) | |
qty_df: reactive.Value[Any | None] = reactive.Value(None) | |
# Hack to handle reactive updates to selection by server side code | |
selections_value: reactive.Value[list[dict[str, int]] | None] = reactive.Value(None) | |
# Datagrid | |
import ipydatagrid as dg # must import within server funciont (Session context) | |
datagrid = dg.DataGrid(DataFrame()) | |
datagrid.renderers = dg_renderers(dg) | |
datagrid.selection_mode = "row" | |
datagrid.layout.height = "300px" | |
register_widget("widget_grid", datagrid) # Link to output_widget | |
# Hack to handle selection saving when updating grid | |
prev_selections: dict[str, int] | None = None | |
@reactive.Calc | |
def get_selection() -> tuple[None, None] | tuple[int, int]: | |
""" | |
Get the current single row selection as row, key or None, None | |
Reactive Inputs: selections_value | |
Other Inputs: datagrid | |
""" | |
selections = selections_value() | |
if ( | |
selections | |
and len(selections) == 1 | |
and selections[0]["r1"] == selections[0]["r2"] | |
): | |
row = selections[0]["r1"] | |
if len(datagrid._data["data"]): | |
visible: DataFrame = datagrid.get_visible_data() | |
if row < len(visible.index): | |
key = int(cast(SupportsInt, visible.index[row])) | |
return (row, key) | |
return None, None | |
### Step 1: Either select data file or use default | |
@reactive.Effect | |
def parse_datafile(): | |
""" | |
Hande datafile selection. Called on start | |
Reactiive Inputs: file_base_datafile | |
Reactive Outputs: base_df, qty_df, selections_value | |
Other Inputs: style_inputs | |
Side-effects: style select controls | |
""" | |
if input.file_base_datafile() is not None: | |
data_file = input.file_base_datafile()[0]["datapath"] | |
else: | |
data_file = default_datafile | |
df: DataFrame = pandas.read_csv( # type: ignore pandas stub is incomplete | |
data_file, | |
dtype={ | |
"Infill": "Int8", | |
"Filament": "Int32", | |
}, | |
converters={ | |
"PrintTime": to_minutes, | |
}, | |
na_filter=False, | |
) | |
# Ensure a Qty column | |
if "Qty" not in df.columns: | |
df.insert(0, "Qty", 0) # type: ignore pandas stub is incomplete | |
# Update Style selection controls based on data and style_inputs | |
def style_choices(part_type: str): | |
"""Get unique styles for part types that are marked Default""" | |
all_choices = [ | |
set(c.split(",")) | |
for c in cast( | |
Iterator[str], | |
df.loc[(df["Type"] == part_type) & (df["Default"] != ""), "Style"], | |
) | |
] | |
choices = set[str].union(*all_choices) | |
return sorted(list(choices)) | |
for s in style_inputs: | |
ui.update_select( | |
s.select_name, | |
choices=style_choices(s.part_type), | |
selected=["Standard", "1x1"], | |
) | |
base_df.set(df) | |
qty_df.set(df) | |
# clear datagrid selections and trigger | |
datagrid.clear_selection() | |
selections_value.set(None) | |
### Step 2 - Generate a table | |
@output | |
@render.text | |
def text_style_error(): | |
""" | |
Render any errors based on style dependencies | |
Reactive Inputs: style select controls | |
Other Inputs: style_inputs, style_dependencies | |
""" | |
return check_style_dependencies(input) | |
@reactive.Effect | |
@reactive.event(input.button_generate_table) | |
def generate_table(): | |
""" | |
Generate a table based on default qtys for the selected styles | |
Reactive Inputs: button_generate_table | |
Reactive Outputs: qty_df, selections_value | |
Other Inputs: style select controls, numeric_width, numeric_height, qty_calculations, style_inputs, style_dependencies | |
""" | |
df = base_df() | |
if df is None or check_style_dependencies(input): | |
return | |
def filter(row: "pandas.Series[Any]"): | |
style = set(cast(str, row["Style"].split(","))) | |
return cast(str, row["Default"]) != "" and ( | |
any( | |
cast(str, row["Type"]) == s.part_type | |
and cast(str, getattr(input, s.select_name)()) in style | |
for s in style_inputs | |
) | |
or (cast(str, row["Type"]) == "Core") | |
) | |
args = ( | |
input.numeric_width(), | |
input.numeric_height(), | |
*(getattr(input, s.select_name)() for s in style_inputs), | |
) | |
def calc_qty(row: "pandas.Series[Any]"): | |
if not filter(row): | |
return 0 | |
return qty_calculations[cast(str, row["Default"])](*args) | |
df = df.assign(Qty=df.apply(calc_qty, axis=1)) | |
if isna(df["Qty"]).any(): # type: ignore pandas stub is incomplete | |
print("Error in calculation") | |
qty_df.set(base_df()) | |
return | |
qty_df.set(df) | |
# clear datagrid selections and trigger | |
datagrid.clear_selection() | |
selections_value.set(None) | |
######## Step 3 - Update Qty | |
@reactive.Effect | |
def update_datagrid(): | |
""" | |
Reactive Input: qty_df, switch_show_qty | |
Reactive Ouptut: datagrid.data | |
Side-Effects: prev_selections | |
""" | |
df = qty_df() | |
if df is None: | |
return None | |
# Add some useful columns like has_qty and sort | |
df = df.assign( | |
ExtPrinTime=df["PrintTime"] * df["Qty"], | |
ExtFilament=df["Filament"] * df["Qty"], | |
has_qty=df["Qty"] > 0, | |
).sort_values(sort_columns, axis=0, ascending=sort_order) | |
# Filter to only used rows | |
if input.switch_show_qty(): | |
df = df.loc[df["has_qty"], :] | |
# Save selections to reset later | |
with reactive.isolate(): | |
nonlocal prev_selections | |
prev_selections = datagrid.selections[0] if datagrid.selections else None | |
# update datagrid. Clears selections | |
datagrid.data = df | |
# if prev_selections: | |
# datagrid.select(prev_selections[0], prev_selections[1], clear_mode="all") | |
@reactive.Effect | |
def update_selections(): | |
""" | |
Handle changes in selection | |
Reactive Inputs: datagrid.selections | |
Reactive Outputs: selections_value | |
Other Inputs: prev_selections | |
Side-Effects: datagrid.select | |
""" | |
nonlocal prev_selections | |
selections = reactive_read(datagrid, "selections") | |
# Hack to resend selections on datagrid data change | |
if not selections and prev_selections is not None: | |
tmp = prev_selections | |
prev_selections = None | |
datagrid.select(tmp["r1"], tmp["c1"], clear_mode="all") | |
selections_value.set([tmp]) | |
else: | |
selections_value.set(selections) | |
@output | |
@render.text | |
def text_selection_name(): | |
""" | |
Generate a unique name | |
Reactive Inputs: selections_value, qty_df | |
Other Inputs: name_columns | |
""" | |
_, key = get_selection() | |
df = qty_df() | |
if df is None or key is None: | |
return None | |
data = df.loc[key, name_columns] | |
return "/".join(str(c).strip() for c in data if c) | |
@reactive.Effect | |
def update_selection_qty(): | |
""" | |
Reactive Inputs: selections_value, qty_df | |
Side-Effects: numeric_selection_qty | |
""" | |
_, key = get_selection() | |
df = qty_df() | |
if df is None: | |
return | |
value = int(df.at[key, "Qty"]) if key is not None else "" | |
# update_numeric can take a "" to clear the field. None does not work | |
ui.update_numeric("numeric_selection_qty", value=value) # type: ignore | |
@reactive.Effect | |
def update_selection_time(): | |
""" | |
Reactive Inputs: selections_value, qty_df | |
Side-Effects: numeric_selection_time | |
""" | |
_, key = get_selection() | |
df = qty_df() | |
if df is None: | |
return | |
value = format_minutes(df.at[key, "PrintTime"]) if key is not None else "" | |
ui.update_text("numeric_selection_time", value=value) | |
@reactive.Effect | |
def update_selection_filament(): | |
""" | |
Reactive Inputs: selections_value, qty_df | |
Side-Effects: numeric_selection_filament | |
""" | |
_, key = get_selection() | |
df = qty_df() | |
if df is None: | |
return | |
value = df.at[key, "Filament"] if key is not None else None | |
value = int(value) if value is not None and notna(value) else "" | |
# update_numeric can take a "" to clear the field. None does not work | |
ui.update_numeric("numeric_selection_filament", value=value) # type: ignore | |
@reactive.Effect | |
@reactive.event(input.button_selection_update) | |
def button_selection_update(): | |
""" | |
Update values in dataframe/datagrid when update button pressed | |
Reactive Inputs: button_selection_update | |
""" | |
df = qty_df() | |
row, key = get_selection() | |
if df is None or row is None: | |
return | |
if input.numeric_selection_qty() is None: | |
return | |
df = df.copy() | |
df.at[key, "Qty"] = int(input.numeric_selection_qty()) | |
df.at[key, "PrintTime"] = to_minutes(input.numeric_selection_time()) | |
df.at[key, "Filament"] = input.numeric_selection_filament() | |
qty_df.set(df) | |
@reactive.Effect | |
@reactive.event(input.link_comp_more) | |
def link_comp_more(): | |
""" | |
Display a modal with more instructions | |
Reactive Input: link_comp_more | |
""" | |
ui.modal_show( | |
ui.modal( | |
ui.p("Update Qty to add, remove or change components"), | |
ui.p("To get a better estimate, fill out Print Time and Filament from your slicer"), | |
ui.p('Use the "Only Show Qty>0" to help narrow down which components to slice'), | |
ui.p('Use the "Clear ALL time and filament" button to clear out the default print times and filament usages'), | |
title="More Info: Update Components", | |
easy_close=True, | |
) | |
) | |
@reactive.Effect | |
@reactive.event(input.button_clear_time_filament) | |
def button_clear_time_filament(): | |
""" | |
Clear all PrintTime and Filament values | |
Reactive Input: button_clear_time_fimament | |
Reactive Ouputs: qty_df | |
Other Inputs: qty_df | |
""" | |
df = qty_df() | |
if df is None: | |
return | |
df = df.assign(PrintTime=NA, Filament=NA) | |
qty_df.set(df) | |
########## Step 5 - Review Totals | |
@output | |
@render.text | |
def text_totals_error(): | |
""" | |
Show any totataling warnings | |
Reactive Input: qty_df | |
""" | |
df = qty_df() | |
if df is None: | |
return None | |
df = df[df["Qty"] > 0] | |
if isna(df["Filament"]).any() or isna(df["PrintTime"]).any(): # type: ignore pandas stub is incomplete | |
return "* Some values in the total calculation not defined. Check the grid for missing Print Time or Filament values" | |
else: | |
return "" | |
@output | |
@render_widget | |
def widget_totals(): | |
""" | |
Generate a datagrid of totals | |
Reactive Inputs: qty_df | |
""" | |
df = qty_df() | |
if df is None: | |
return dg.DataGrid(DataFrame()) | |
df = df[df["Qty"] > 0] | |
df = df.assign( | |
PrintTime=df["Qty"] * df["PrintTime"], Filament=df["Qty"] * df["Filament"] | |
) | |
pt = df.pivot_table( | |
values=["PrintTime", "Filament"], | |
index=["Material"], | |
aggfunc={"PrintTime": "sum", "Filament": "sum"}, | |
) | |
grid = dg.DataGrid(pt, renderers=dg_renderers(dg)) | |
grid.layout.height = "80px" | |
return grid | |
###### Step 6 - Download datafile | |
@session.download(filename="StageTop.csv", media_type="text/csv") | |
def button_download_data(): | |
""" | |
Reactive Inputs: qty_df | |
""" | |
df = qty_df() | |
if df is None: | |
return | |
df = df.copy() | |
df["PrintTime"].apply(format_minutes) | |
yield df.to_csv() | |
# @output(suspend_when_hidden=False) | |
# @render.text | |
# def grid_has_selections(): | |
# selections = reactive_read(datagrid, "selections") | |
# | |
# if ( | |
# selections | |
# and len(selections) == 1 | |
# and selections[0]["r1"] == selections[0]["r2"] | |
# ): | |
# return "yes" | |
# return "no" | |
app = App(app_ui, server, debug=True) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
ipydatagrid |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Type | Name | Style | Version | Wave | Default | Infill | Bed | PrintTime | Filament | Material | |
---|---|---|---|---|---|---|---|---|---|---|---|
Assessory | Component Cup | Barrel | 2 | 3 | PLA | ||||||
Assessory | Component Cup Half | 1 | 1 | 15 | Skirt | 1:56 | 19 | PLA | |||
Assessory | Component Cup Half | Barrel | 1 | 2 | PLA | ||||||
Assessory | Component Cup | 1 | 1 | 15 | Skirt | 2:51 | 28 | PLA | |||
Assessory | Component Cup | Barrel | 1 | 2 | PLA | ||||||
Assessory | Counter Wheel | 1 | 3 | PLA | |||||||
Assessory | Dice Tower | 3 | 1 | 15 | Skirt | 13:14 | 148 | PLA | |||
Assessory | Dice Tower | SciFi | 1 | 3 | PLA | ||||||
Assessory | Dice Tower | Stonghold | 1 | 2 | PLA | ||||||
Assessory | Dice Tower | Tavern | 1 | 2 | PLA | ||||||
Assessory | Dice Tray | 3 | 1 | 15 | Skirt | 5:30 | 60 | PLA | |||
Assessory | Dice Tray | No Logo | 3 | 1 | 15 | Skirt | 5:21 | 60 | PLA | ||
Assessory | Dice Tray | SciFi | 1 | 3 | PLA | ||||||
Assessory | Dice Tray | Stronghold | 1 | 2 | PLA | ||||||
Assessory | Dice Tray | Tavern | 1 | 2 | PLA | ||||||
Core | Frame Lock | 1 | 1 | FrameLock | 15 | Skirt | 0:51 | 9 | PLA | ||
Core | Frame | 1 | 1 | Frame | 15 | None | 11:00 | 125 | PLA | ||
Core | Tile Lock | 1 | 1 | 15 | Skirt | 0:18 | 2 | PLA | |||
Core | Tile Lock | 2 | 1.5 | TileLock | 15 | Skirt | 0:18 | 2 | PLA | ||
Leg | Foot | Elite,Elite - Short,Elite - Adj | 1 | 1 | Frame | 15 | Skirt | TPU | |||
Leg | Foot | Lite w/Foot | 1 | 1.5 | Frame | TPU | |||||
Leg | Foot | SciFi | 1 | 2 | Frame | TPU | |||||
Leg | Foot | Standard,Standard - Short | 1 | 1 | Frame | 15 | Skirt | 1:59 | 10 | TPU | |
Leg | Foot | Stronghold | 1 | 2 | Frame | TPU | |||||
Leg | Foot | Tavern | 1 | 2 | Frame | TPU | |||||
Leg | Leg Adj. Bottom | Elite - Adj | 1 | 1.5 | Frame | 10 | Brim | 1:46 | 21 | PLA | |
Leg | Leg Adj. Lock Nut | Elite - Adj | 1 | 1.5 | Frame | 15 | Skirt | 0:22 | 3 | PLA | |
Leg | Leg Adj. Top | Elite - Adj | 1 | 1.5 | Frame | 10 | Brim | 2:57 | 30 | PLA | |
Leg | Leg | Elite | 1 | 1 | Frame | 10 | Brim | 4:34 | 53 | PLA | |
Leg | Leg | Elite - Short | 1 | 1.5 | Frame | 10 | Brim | 0:56 | 11 | PLA | |
Leg | Leg | Lite | 1 | 1 | Frame | 10 | Brim | 3:25 | 39 | PLA | |
Leg | Leg | Lite w/Foot | 2 | 1.5 | Frame | 10 | Brim | 3:29 | 40 | PLA | |
Leg | Leg | SciFi | 1 | 2 | Frame | PLA | |||||
Leg | Leg | Standard | 1 | 1 | Frame | 10 | Brim | 5:03 | 60 | PLA | |
Leg | Leg | Standard - Short | 1 | 1.5 | Frame | 10 | Brim | 1:01 | 12 | PLA | |
Leg | Leg | Stronghold | 1 | 2 | Frame | PLA | |||||
Leg | Leg | Tavern | 1 | 2 | Frame | PLA | |||||
Playtile | Component Corner | 1 | 1 | 10 | Skirt | 9:59 | 119 | PLA | |||
Playtile | Component | 1 | 1 | 10 | Skirt | 6:38 | 79 | PLA | |||
Playtile | Component | Keep | 1 | 3 | PLA | ||||||
Playtile | Component | Stronghold | 1 | 3 | PLA | ||||||
Playtile | Component | Tavern | 1 | 3 | PLA | ||||||
Playtile | Corner | 1x1 | 1 | 1 | 4 | 10 | Skirt | 9:15 | 114 | PLA | |
Playtile | Corner | 2x2 | 1 | 1 | 4 | 10 | Skirt | 8:37 | 109 | PLA | |
Playtile | Corner | 4x4 | 1 | 1 | 4 | 10 | Skirt | 8:20 | 106 | PLA | |
Playtile | Corner | Blank | 1 | 1 | 4 | 10 | Skirt | 7:59 | 105 | PLA | |
Playtile | Corner | Stronghold | 1 | 3 | 4 | PLA | |||||
Playtile | Corner | Tavern | 1 | 3 | 4 | PLA | |||||
Playtile | Counter Wheel | 1 | 3 | PLA | |||||||
Playtile | Dice Tray | 1 | 1 | 10 | Skirt | 8:28 | 104 | PLA | |||
Playtile | Full | 1x1 | 1 | 1 | Tile | 10 | Skirt | 12:09 | 151 | PLA | |
Playtile | Full | 2x2 | 1 | 1 | Tile | 10 | Skirt | 11:19 | 144 | PLA | |
Playtile | Full | 4x4 | 1 | 1 | Tile | 10 | Skirt | 10:57 | 140 | PLA | |
Playtile | Full | Blank | 1 | 1 | Tile | 10 | Skirt | 10:26 | 138 | PLA | |
Playtile | Full | Stronghold | 1 | 3 | Tile | PLA | |||||
Playtile | Full | Tavern | 1 | 3 | Tile | PLA | |||||
Playtile | Half | 1x1 | 1 | 1 | TileHalf | 10 | Skirt | 6:12 | 76 | PLA | |
Playtile | Half | 2x2 | 1 | 1 | TileHalf | 10 | Skirt | 5:47 | 73 | PLA | |
Playtile | Half | 4x4 | 1 | 1 | TileHalf | 10 | Skirt | 5:35 | 71 | PLA | |
Playtile | Half | Blank | 1 | 1 | TileHalf | 10 | Skirt | 5:23 | 70 | PLA | |
Playtile | Half | Stronghold | 1 | 3 | TileHalf | PLA | |||||
Playtile | Half | Tavern | 1 | 3 | TileHalf | PLA | |||||
Playtile | Mixed Card | 1 | 3 | PLA | |||||||
Playtile | Mountable | Elite | 1 | 3 | PLA | ||||||
Playtile | Mountable | Lite | 1 | 3 | PLA | ||||||
Playtile | Mountable | Standard | 1 | 3 | PLA | ||||||
Playtile | Quarter | 1x1 | 1 | 1 | 10 | Skirt | 3:11 | 38 | PLA | ||
Playtile | Quarter | 2x2 | 1 | 1 | 10 | Skirt | 2:58 | 37 | PLA | ||
Playtile | Quarter | Blank | 1 | 1 | 10 | Skirt | 2:48 | 36 | PLA | ||
Playtile | Quarter | Stronghold | 1 | 3 | PLA | ||||||
Playtile | Quarter | Tavern | 1 | 3 | PLA | ||||||
Playtile | Triple Component | 1 | 1 | 10 | Skirt | 8:39 | 109 | PLA | |||
Rail | Anti Corner Base | Elite | 1 | 3 | PLA | ||||||
Rail | Anti Corner Top | Elite | 1 | 3 | TPU | ||||||
Rail | Card Top | Elite | 1 | 1.5 | TPU | ||||||
Rail | Card Top | Elite | 2 | 2 | TPU | ||||||
Rail | Card | Standard | 1 | 1 | 10 | Skirt | 9:13 | 116 | PLA | ||
Rail | Card | Standard | 2 | 3 | PLA | ||||||
Rail | Clip | Elite | 1 | 1 | Rail | 15 | Skirt | 0:51 | 10 | TPU | |
Rail | Clip | Standard | 1 | 1 | Rail | 15 | Skirt | 0:48 | 10 | PLA | |
Rail | Component Top | Elite | 1 | 1.5 | TPU | ||||||
Rail | Corner Base | Elite | 1 | 1 | 4 | 15 | Skirt | 5:39 | 68 | PLA | |
Rail | Corner Top | Elite | 1 | 1 | 4 | 10 | Skirt | 12:22 | 77 | TPU | |
Rail | Corner | Lite | 1 | 1.5 | 4 | 10 | Skirt | 2:39 | 32 | PLA | |
Rail | Corner | SciFi | 1 | 2 | 4 | PLA | |||||
Rail | Corner | Standard | 1 | 1 | 4 | 10 | Skirt | 8:01 | 107 | PLA | |
Rail | Corner | Stronghold | 1 | 2 | 4 | PLA | |||||
Rail | Corner | Tavern | 1 | 2 | 4 | PLA | |||||
Leg | Joint - Rail Lock | Lite - Short | 1 | 1.5 | Tile | 15 | Skirt | 0:39 | 6 | PLA | |
Rail | Rail Lock | Lite | 1 | 1.5 | LiteRailLock | 15 | Skirt | 0:32 | 4 | PLA | |
Rail | Rail | Elite | 1 | 1 | Rail | 15 | Skirt | 5:08 | 62 | PLA | |
Rail | Rail | Lite | 1 | 1.5 | Rail | 10 | Skirt | 2:40 | 32 | PLA | |
Rail | Rail | SciFi | 1 | 2 | Rail | PLA | |||||
Rail | Rail | Standard | 1 | 1 | 10 | Skirt | 7:04 | 94 | PLA | ||
Rail | Rail | Standard | 2 | 3 | Rail | PLA | |||||
Rail | Rail | Stronghold | 1 | 2 | Rail | PLA | |||||
Rail | Rail | Tavern | 1 | 2 | Rail | PLA | |||||
Rail | Top | Elite | 1 | 1 | Rail | 10 | Skirt | 10:40 | 68 | TPU | |
Storage | Corner Rail Carrier - Handle | Standard | 1 | 3 | PLA | ||||||
Storage | Corner Rail Carrier | Standard | 1 | 3 | PLA | ||||||
Storage | Frame Holder Handle | 1 | 3 | PLA | |||||||
Storage | Frame Holder | 1 | 3 | PLA | |||||||
Storage | Leg Carrier | 1 | 1.5 | 15 | Skirt | 12:05 | 135 | PLA | |||
Storage | Playtile Holder | 1 | 3 | PLA | |||||||
Storage | Rail Holder - Part A | Standard | 1 | 3 | PLA | ||||||
Storage | Rail Holder - Part B | Standard | 1 | 3 | PLA |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment