Skip to content

Instantly share code, notes, and snippets.

@steamraven
Last active May 19, 2023 17:58
Show Gist options
  • Save steamraven/c5536d490468e8106caf8f21abd0a0f6 to your computer and use it in GitHub Desktop.
Save steamraven/c5536d490468e8106caf8f21abd0a0f6 to your computer and use it in GitHub Desktop.
Table Creator for StageTop
"""
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)
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