diff --git a/app.py b/app.py index 102c577..88a2ebc 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ from flask import Flask, Response, jsonify, request, send_from_directory from busbar_export import WRITERS import storage import holder +import tracks APP_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_DIR = os.path.join(APP_DIR, "static") @@ -50,6 +51,12 @@ def scad_page(): return send_from_directory(STATIC_DIR, "scad.html") +@app.get("/tracks") +def tracks_page(): + """Wooden-train track generator (IKEA Lillabo / Brio compatible).""" + return send_from_directory(STATIC_DIR, "tracks.html") + + @app.get("/api/health") def health(): return jsonify({"status": "ok", "openscad": holder.openscad_available()}) @@ -104,6 +111,25 @@ def holder_source(): return jsonify({"error": str(e)}), 500 +@app.get("/api/tracks/params") +def tracks_params(): + return jsonify(tracks.schema_dict()) + + +@app.post("/api/tracks/render") +def tracks_render(): + body = request.get_json(silent=True) or {} + part = body.get("part") + params = body.get("params") or {} + try: + data, dims = tracks.render(part, params) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except (FileNotFoundError, RuntimeError) as e: + return jsonify({"error": str(e)}), 500 + return _stl_response(data, dims, f"track_{part or 'part'}.stl") + + @app.post("/api/scad/render") def scad_render(): """Render arbitrary user-supplied OpenSCAD source (no holder schema). diff --git a/scad/train_tracks.scad b/scad/train_tracks.scad new file mode 100644 index 0000000..448e69d --- /dev/null +++ b/scad/train_tracks.scad @@ -0,0 +1,1522 @@ +// Train tracks generator for IKEA Lillabo, Brio and others +//// by torwan @ January 8th, 2023 + +// Description / Hints: +// - See more details on https://www.thingiverse.com/thing:5598668 +// - All dimensions in [mm]. +// - Before printing any "bigger" part generated by this generator, +// I highly recommend to print a tester-track relevant to the system +// you posses. Use the "track_tester()" module to generate a tester. +// - To print STL file, render the object using F6 and save +// an STL file using F7. +// - Standard lengths of the IKEA Lillabo straight tracks (without +// a plug): 50mm, 100mm, 146mm, 205mm, 240mm. +// - Standard arcs in the IKEA Lillabo tracks family (original tracks +// measured): 45deg + inner radius 185mm which gives U-turn inner +// diameter 370mm, 22.5deg + inner radius 180mm which gives U-turn +// inner diameter 360mmm. +// - Arc parameters used in J'adore system (sold e.g., by Rossmann): +// 45deg, inner radius 86mm, => U-turn inner diameter 172mm +// - Standard lengths of the Brio straight tracks (without a plug): +// 145mm. +// - $fn controls number of a circle fragments. I recommend to use +// $fn = 300;, especially for larger arcs and bridge parts. Keep +// in mind that the larger value of $fn, the longer rendering will +// take, but also the smoother "roundy" parts of your tracks will +// be. When tinkering around and playing with different parameters +// values, I recommend using smaller $fn value unless you have rally +// fast CPU/GPU PC configuration. + +//$fn = 300; +$fn = 150; + +// ### General track dimensions + +// Track dimensions (based on original IKEA Lillabo and BRIO tracks). +// You should not change these values unless you are going to tune this +// generator to some really different system than the ones available +// commonly on the market. +track_width = 40; +track_height = 12; +track_well_depth = 2.9; +track_well_width_top = 6.75; +track_well_width_bottom = 4.75; +track_well_spacing = 25.7; +track_chamfer = 1.5; + + +// ### DO NOT CHANGE BELOW DEFINITIONS UNLESS IT IS REALLY NECESSARY +// ### (use the "general" variables to play with various dimensions instead) + +// Track Plug IKEA Lillabo (do not change) +ikea_track_plug_radius = 6.25; // 6.25 +ikea_track_plug_radius_ext = 6.5; // 6.5 for a dog-bone with springy plug +ikea_track_plug_neck_width = 6.1; // 6.1 +ikea_track_plug_neck_length = 11; // 11 +ikea_track_plug_txt = "I"; + +// Track Nest IKEA Lillabo (do not change) +ikea_track_nest_radius = 6.4; // 6.4 +ikea_track_nest_neck_width = 6.3; // 6.3 +ikea_track_nest_neck_length = 11; // 11 +ikea_track_nest_txt = "I"; + +// Track Plug Brio (do not change) +brio_track_plug_radius = 6.25; // 6.25 +brio_track_plug_radius_ext = 6.5; // 6.5 for a dog-bone with springy plug +brio_track_plug_neck_width = 6.1; // 6.1 +brio_track_plug_neck_length = 12; // 12 +brio_track_plug_txt = "B"; + +// Track Nest Brio (do not change) +brio_track_nest_radius = 6.4; // 6.4 +brio_track_nest_neck_width = 6.3; // 6.3 +brio_track_nest_neck_length = 12; // 12 +brio_track_nest_txt = "B"; + +// Bridge components (do not change) +bridge_pillar_depth = 10; +slope_straight_nest_length = 20; //length of the straight ending (with nest) +slope_straight_plug_length = 10; //length of the straight ending (without plug) + +// Helpers (do not change) +tr_wl_w_stick_out = (track_well_width_top-track_well_width_bottom)/2; +tr_co_w = 0.75 * (track_well_spacing-track_well_width_top); // track cutout width +cc = 0.001; +switch_cut_ext = 1; + +// ### USE BELOW VARIABLES TO CHOOSE YOUR TARGET SYSTEM +// ### Below variables are used by majority of modules in this library. +// ### Feel free to define your own values here instead of using +// ### pre-defined parameters for specific systems. +// ### For example: track_plug_radius = 6.45; + +// Track Plug - choose your system here or define your own values +track_plug_radius = ikea_track_plug_radius; +track_plug_radius_ext = ikea_track_plug_radius_ext; +track_plug_neck_width = ikea_track_plug_neck_width; +track_plug_neck_length = ikea_track_plug_neck_length; +track_plug_txt = ikea_track_plug_txt; + +// Track Nest - choose your system here or define your own values +track_nest_radius = ikea_track_nest_radius; +track_nest_neck_width = ikea_track_nest_neck_width; +track_nest_neck_length = ikea_track_nest_neck_length; +track_nest_txt = ikea_track_nest_txt; + + +// ### TESTING / GENERATION AREA / EXAMPLES +// ### Below I have provided many examples of how to call a generator module. +// ### They should help to learn quickly how to generate a desired model. +// ### When trying different module, always remember to comment back +// ### a previously used one :) +// ### If you have any doubts regarding some parameters, see detailed description +// ### provided in the front of each module definition. + +// --------------------------------- +//TRACK TESTER +// NB: this top-level call was commented out by busbar-designer so the file +// can be `include`d as a library without emitting any geometry. The /tracks +// page wraps and calls the right module explicitly. +//track_tester(); + +// --------------------------------- +// TRACKS ADAPTER +//tracks_adapter(length = 30, nest = "B", plug = "I"); +//tracks_adapter(length = 30, nest = "I", plug = "B"); + +// --------------------------------- +// STRAIGHT track examples +/* +track(length = 100, + end1 = "plug", + end2 = "nest", + cutout = true); +*/ + +/* +track(length = 25, + cutout = true, + end1 = "plug", + end2 = "nest", + part_chamfers = true, + grooves = true); +*/ + +/* +track(length = 50, + cutout = true, + end1 = "plug", + end2 = "nest", + part_chamfers = true, + grooves = true); +*/ + +// --------------------------------- +// ARC track examples +//track_arc(); +//track_arc(angle = 90, radius = 86, both_sides = true); +//track_arc(angle = 45, radius = 185, both_sides = true); +//track_arc(angle = 22.5, radius = 180); +//track_arc(angle = -45, radius = 185, cutout = false, both_sides = false); + +/* +track_arc(angle = -90, + radius = 86, + end1 = "nest", + end2 = "plug", + grooves = true, + both_sides = true, + cutout = true, + cutout_corr = 0, + part_chamfers = true); +*/ + +/* +track_arc(angle = -45, + radius = 86, + end1 = "nest", + end2 = "plug", + grooves = true, + both_sides = true, + cutout = true, + cutout_corr = 0, + part_chamfers = true); +*/ +/* +track_arc(angle = 45, + radius = 86, + end1 = "nest", + end2 = "nest", + grooves = true, + both_sides = true, + cutout = true, + cutout_corr = 0, + part_chamfers = true); +*/ +/* +track_arc(angle = -45, + radius = 185, + end1 = "nest", + end2 = "nest", + grooves = true, + both_sides = false, + cutout = true, + cutout_corr = 0, + part_chamfers = true); +*/ + +/* +track_arc(radius = 86, angle = -90, + end1 = "plug", end2 = "plug", + grooves = true, + both_sides = true, + cutout = true, + cutout_corr = 15, + part_chamfers = true); +*/ + +// --------------------------------- +// DOGBONE +//track_dogbone(); + +// --------------------------------- +// INTERSECTION examples + +/* +intersection(angle = 90, + lengthA = 75, // 75mm is too short for Brio + lengthB = 75, // 75mm is too short for Brio + both_sides = true); +*/ +/* +intersection(angle = 45, + lengthA = 146, + lengthB = 146, + end1A = "nest", + end2A = "plug", + end1B = "nest", + end2B = "plug", + both_sides = true); +*/ +/* +intersection(angle = 90, + lengthA = 100, + lengthB = track_width, + end1A = "nest", + end2A = "plug", + end1B = "plug", + end2B = "plug", + both_sides = true); +*/ + + +// --------------------------------- +// BRIDGE set examples +// - what_to_generate - controls what is generated by the module, +// allowed values: +// -- 0 (zero) - shows a bridge overview with calculation of +// the bridge height +// -- 1 - shows ONLY a bridge ground part +// -- 2 - shows ONLY a bridge slope (upper) part +// -- 3 - shows ONLY a bridge pillar part + + +// Showcase 15 degrees, radius 100mm, 205mm straight part, 50mm pillar length: +/* +generate_bridge(what_to_generate = 0, + bridge_angle = 15, + slope_radius = 100, + straight_part_l = 205, + pillar_l = 50, + cutout = true); +*/ + +// Showcase 20 degrees, radius 200mm, 146mm straight part, 50mm pillar length: +/* +generate_bridge(what_to_generate = 1, + bridge_angle = 20, + slope_radius = 200, + straight_part_l = 146, + pillar_l = 50, + cutout = true); +*/ + +// Showcase 14 degrees, radius 100mm, 205mm straight part, 50mm pillar length: +//generate_bridge(what_to_generate = 0); + + +// --------------------------------- +// SNAKE track examples +/* +snake_track(angle = 20, + radius = 186, + target_length = 150, + end1 = "nest", + end2 = "plug", + cutout = false, + both_sides = false, + cutout_corr = 0); +*/ + +/* +snake_track(angle = 35.775, + radius = 86, + cutout = true, + cutout_corr = 2, + target_length = 146, + both_sides = true, + end1 = "nest", + end2 = "plug"); +*/ +/* +snake_track(angle = 70, + radius = 80, + target_length = 300, + end1 = "nest", + end2 = "plug", + cutout = false, + both_sides = false, + cutout_corr = 0); +*/ +// --------------------------------- +// SWITCH track examples +// If a model does not look good in the preview window, render it using F6 + +/* +switch(angleR = 45, radiusR = 86, endR = "nest", + angleL = 0, radiusL = 0, endL = "nest", + lengthS = 100, endS = "nest", + endCommon = "plug", + both_sides = true); +*/ + +/* +switch(angleR = 45, radiusR = 86, endR = "nest", + angleL = 45, radiusL = 86, endL = "nest", + lengthS = 0, endS = "none", + endCommon = "nest", + both_sides = true); +*/ + +/* +switch(angleR = 45, radiusR = 86, endR = "nest", + angleL = 45, radiusL = 86, endL = "plug", + lengthS = 146, endS = "nest", + endCommon = "nest", + both_sides = true); +*/ + +/* +switch(angleR = 45, radiusR = 86, endR = "plug", + angleL = 45, radiusL = 86, endL = "plug", + lengthS = 146, endS = "nest", + endCommon = "nest", + both_sides = true); +*/ + +/* +switch(angleR = 45, radiusR = 86, endR = "nest", + angleL = 45, radiusL = 86, endL = "nest", + lengthS = 100, endS = "nest", + endCommon = "nest", + both_sides = true); +*/ + +/* +switch(angleR = 90, radiusR = 86, endR = "plug", + angleL = 45, radiusL = 86, endL = "plug", + lengthS = 100, endS = "plug", + endCommon = "nest", + both_sides = true); +*/ + +// ########## End of testing area ########## + +// ### TRACK TESTER MODULE +// Input parameters: +// - tester_length - length of a tester without a plug + +module track_tester(tester_length = 25) { + difference() { + translate([0, 0, -track_height + track_well_depth + 0.4]) + track(length = tester_length, + cutout = true, + end1 = "nest", + end2 = "plug", + part_chamfers = true, + grooves = true); + translate([-cc, -cc, -track_height]) + cube([2*tester_length, 2*cc + track_width , track_height]); + } +} + +// ### SWITCH TRACK GENERATOR MODULE +// Railway switch() module allows to generate various types of switches. +// You can independently control if it has left and/or straight and/or +// right leg. You can control what kind of ending each of leg has. +// Input parameters: +// - angleR - desired angle of the right turn +// - radiusR - desired radius of the right turn, zero disables the leg +// - endR - desired end type of the right leg ("plug", "nest" or "none") +// - angleL, radiusL, endL - left leg parameters +// - lengthS - length of the middle, straight leg +// - endCommon - common end type "plug", "nest" or "none") +// - both_sides - controls if the grooves should be generated only on +// the top or also on the bottom: +// -- true - grooves on both sides +// -- false - grooves only on the top + +module switch(angleR = 45, radiusR = 86, endR = "nest", + angleL = 45, radiusL = 86, endL = "plug", + lengthS = 146, endS = "nest", + endCommon = "nest", + both_sides = true) { + + difference() { + union() { // Left + Straight + Right main bodies + + // Arc to the left + if (radiusL > 0) { + track_arc(angle = angleL, + radius = radiusL, + part_chamfers = true, + grooves = false, + both_sides = false, + cutout = false, + end1=endCommon, end2="none"); + } + + // Arc to the right + if (radiusR > 0) { + track_arc(angle = -angleR, + radius = radiusR, + part_chamfers = true, + grooves = false, + both_sides = false, + cutout = false, + end1=endCommon, end2="none"); + } + + // Straight part + if (lengthS > 0) { + translate([track_width, 0, 0]) + rotate([0, 0, 90]) + track(length = lengthS, + cutout = false, + part_chamfers = true, + grooves = false, + end1 = endCommon, end2 = endS); + } + } //union + + // grooves and the nest (if requested) on the left turn + if (radiusL > 0) { + translate([-radiusL, 0, 0]) + rotate_extrude(angle = angleL, convexity = 10) + translate([radiusL, 0 , 0]) + grooves(both_sides); + // Nest if requested + if (endL == "nest") { + translate([-radiusL , 0, 0]) + rotate([0, 0, angleL]) + translate([radiusL + track_width/2, 0, 0]) + rotate([0, 0, -90]) + track_nest(); + } + // When the left turn is too "short", and the straight part + // is requested, the right turn ends up inside the straight part. + // In such a case, additional cutting is required to provide + // sufficient amount of space for adjacent track. + translate([-radiusL , 0, 0]) + rotate([0, 0, angleL]) + rotate_extrude(angle = angleL/4, convexity = 10) + translate([radiusL, 0, 0]) + translate([-switch_cut_ext/2, 0, 0]) + square([track_width + switch_cut_ext, track_height]); + } //(radiusL > 0) + + // grooves and the nest (if requested) on the right turn + if (radiusR > 0) { + translate([radiusR+track_width, 0, 0]) + rotate_extrude(angle = -angleR, convexity = 10) + translate([-radiusR-track_width, 0 , 0]) + grooves(both_sides); + // Nest if requested + if (endR == "nest") { + translate([radiusR+track_width, 0, 0]) + rotate([0, 0, -angleR]) + translate([-radiusR-track_width/2, 0, 0]) + rotate([0, 0, -90]) + track_nest(); + } + + // When the right turn is too "short", and the straight part + // is requested, the right turn ends up inside the straight part. + // In such a case, additional cutting is required to provide + // sufficient amount of space for adjacent track. + translate([radiusR+track_width, 0, 0]) + rotate([0, 0, -angleR]) + rotate_extrude(angle = -angleR/4, convexity = 10) + translate([-radiusR-track_width, 0, 0]) + translate([-switch_cut_ext/2, 0, 0]) + square([track_width + switch_cut_ext, track_height]); + } // (radiusR > 0) + + + // grooves on the straight part + if (lengthS > 0) { + translate([0, lengthS, 0]) + rotate([90, 0, 0]) + linear_extrude(lengthS) + grooves(both_sides); + } + + + } //main difference + + // Generating plugs + if (endL == "plug") { + translate([-radiusL, 0, 0]) + rotate([0, 0, angleL]) + translate([radiusL + track_width/2, 0, 0]) + rotate([0, 0, 90]) + track_plug(); + } + if (endR == "plug") { + translate([radiusR+track_width, 0, 0]) + rotate([0, 0, -angleR]) + translate([-radiusR - track_width/2, 0, 0]) + rotate([0, 0, 90]) + track_plug(); + } + + } + +// ### ARC TRACK GENERATOR MODULE +// track_arc() module allows to generate various types of arcs. +// You can control angle and radius of generated arcs. +// Input parameters: +// - angle - desired angle of an arc: +// -- negative values - arc turns right +// -- positive values - arc turns left +// - radius - desired radius of the arc +// - end1, end2 - desired end type of the arc ("plug", "nest" or "none") +// - grooves - enables or disables generation of grooves: +// -- true - grooves enabled +// -- false - grooves disabled +// - cutout - controls if there should be a cutout inside the arc body, +// this is to help the part to "lose some weight" +// -- true - cutout enabled +// -- false - cutout disabled +// - cutout_corr - additional angle offset (in degrees) which will be +// used by the generator for the cutout starting and ending angle, +// in general only positive values make sense to shorten the cutout +// angular length +// - both_sides - controls if the grooves should be generated only on +// the top or also on the bottom: +// -- true - grooves on both sides +// -- false - grooves only on the top +// - part_chamfers - controls if the part should have chamfers: +// -- true - chamfers enabled +// -- false - chamfers disabled + +module track_arc(radius = 185, angle = 45, + end1 = "plug", end2 = "nest", + grooves = true, + both_sides = false, + cutout = true, + cutout_corr = 0, //cutout start/end correction angle + part_chamfers = true) { + + radius_corr = (angle > 0) ? radius : -radius - track_width; + angle_abs = abs(angle); + angle_dir = (angle >= 0) ? 1 : -1; + + a = radius+(track_width/2); + b = track_nest_neck_length + track_nest_radius; + cutout_start_angle = (end1 == "nest") ? cutout_corr + atan((tr_co_w + b) / a) : cutout_corr + atan(tr_co_w / a); + cutout_end_angle = (end2 == "nest") ? cutout_corr + atan((tr_co_w + b) / a) : cutout_corr + atan(tr_co_w /a); + +// echo(str(cutout_start_angle)); +// echo(str(cutout_end_angle)); + + translate([-radius_corr, 0, 0]) { + difference() { + rotate_extrude(angle = angle, convexity = 10) + translate([radius_corr, 0 , 0]) + track_blueprint(grooves, both_sides, part_chamfers); + + if (end1 == "nest") { + rotate([0, 0, 0]) + translate([radius_corr + track_width/2, 0, 0]) + rotate([0, 0, 90]) + track_nest(); + } + if (end2 == "nest") { + rotate([0, 0, angle]) + translate([radius_corr + track_width/2, 0, 0]) + rotate([0, 0, -90]) + track_nest(); + } + + // Cutout + if (cutout) { + if ((angle_abs-cutout_end_angle-cutout_start_angle) > 0) { + rotate([0, 0, angle_dir*cutout_start_angle]) { + rotate_extrude(angle = angle-angle_dir*(cutout_start_angle+cutout_end_angle), convexity = 10) + translate([radius_corr+track_width/2 - tr_co_w/2, -cc , 0]) + square([tr_co_w, track_height + 2*cc]); + translate([radius_corr+track_width/2, -cc , 0]) + cylinder(d = tr_co_w, h = track_height + 2*cc); + } + rotate([0, 0, angle-angle_dir*cutout_end_angle]) { + translate([radius_corr+track_width/2, -cc , 0]) + cylinder(d = tr_co_w, h = track_height + 2*cc); + } + } //if (minimum length) + } //if (cutout) + } + + // Generating plugs + if (end1 == "plug") { + translate([radius_corr + track_width/2, 0, 0]) + rotate([0, 0, -90]) + track_plug(); + } + if (end2 == "plug") { + rotate([0, 0, angle]) + translate([radius_corr + track_width/2, 0, 0]) + rotate([0, 0, 90]) + track_plug(); + } + } +} + +// ### SNAKE TRACK GENERATOR MODULE +// Snake_track() module allows to generate various types of snake tracks. +// Snake track consists of two adjacent arcs extended by two straight +// tracks on both sides to reach total target length (along Y axis). +// It can only shift the axis to the right, there is no option to generate +// a snake track shifting to the left (TO DO in the future). Print it +// with "both_sides = true" to get the grooves also on the bottom and you +// can flip the part up-side-down to get the snake track shifting to the left. +// Input parameters: +// - angle - desired angle of a singular arc used to create the snake track, +// only positive values make sense +// - radius - desired radius of a singular arc the used to create a snake +// track +// - end1, end2 - desired end type of the snake track ("plug", "nest" or "none") +// - cutout - controls if there should be cutouts inside the snake track body, +// this is to help the part to "lose some weight" +// -- true - cutout enabled +// -- false - cutout disabled +// - cutout_corr - additional angle offset (in degrees) which will be +// used by the generator for the cutout starting and ending angle for +// each arc part used to create the snake track, in general only positive +// values make sense +// - both_sides - controls if the grooves should be generated only on +// the top or also on the bottom: +// -- true - grooves on both sides +// -- false - grooves only on the top + +module snake_track(angle = 45, + radius = 86, + cutout = true, + cutout_corr = 0, + target_length = 146, + both_sides = true, + end1 = "plug", + end2 = "nest") { + + angle = (angle <= 90) ? angle : 90; + + h1 = sin(angle) * radius; + h2 = sin(angle) * (radius + track_width); + + hf = tan(angle) * (radius + track_width); + hf1 = hf - h1; + hf2 = hf - h2; + beta = 180 - 90 - angle; + //l1 = tan(beta) * (hf - h1); + //l2 = tan(beta) * (hf - h2); + l1 = (angle != 90) ? tan(beta) * (hf - h1) : radius + track_width; + l2 = (angle != 90) ? tan(beta) * (hf - h2) : radius + track_width; + hc = h1+h2; // length of the curvy part + side_ext = (target_length - hc)/2; + + echo(str("Length of the curvy part (along Y axis): ", hc)); + assert(hc= 0) { + hull() { + translate([cutout_start, track_width/2, -cc]) + cylinder(d = tr_co_w, h = track_height + 2*cc); + translate([length - cutout_end, track_width/2, -cc]) + cylinder(d = tr_co_w, h = track_height + 2*cc); + } + } //if (minimum length) + } //if (cutout) + + // Corner chamfers + if (end1_chamfers) { + for (y = [ 0, track_width]) { + translate([0, y, track_height/2]) + rotate([0, 0, 45]) + cube([track_chamfer, + track_chamfer, + track_height], + center = true); + } + } + if (end2_chamfers) { + for (y = [ 0, track_width]) { + translate([length, y, track_height/2]) + rotate([0, 0, 45]) + cube([track_chamfer, + track_chamfer, + track_height], + center = true); + } + } + + } //main difference + + // Generating plugs + if (end1 == "plug") { + install_plug(180, 0); + } + if (end2 == "plug") { + install_plug(0, length); + } + +} + +// Helping module for track() module - installs a plug +module install_plug(plug_rotate = 180, length = 0) { + translate([length, track_width/2, 0]) + rotate([0, 0, plug_rotate]) + track_plug(); + +} + +// Helping module for track() module - installs a nest +module install_nest(nest_rotate = 180, length = 0) { + translate([length, track_width/2, 0]) + rotate([0, 0, nest_rotate]) + track_nest(); + translate([length, track_width/2, track_height/2 - cc]) + rotate([0, 0, 45]) + cube([track_nest_neck_width, + track_nest_neck_width, + track_height + 2*cc], + center = true); +} + +// ### BRIDGE GENERATOR MODULE +// Input parameters: +// - what_to_generate - controls what is generated by the module, +// allowed values: +// -- 0 (zero) - shows a bridge overview with calculation of +// the bridge height +// -- 1 - shows ONLY a bridge ground part +// -- 2 - shows ONLY a bridge slope (upper) part +// -- 3 - shows ONLY a bridge pillar part +// - bridge_angle - an angle used to generate "slope" parts of +// the bridge components +// - slope_radius - radius used when generating "slope" parts +// - straight_part_l - straight track length which will be connecting +// bridge ground and upper parts, this is needed in order to +// calculate overall bridge height and to present an overview +// - cutout - used for material saving, it cuts out the middle part +// from a track body if the track path is of sufficient length: +// -- true - the module will try to make a cutout +// -- false - cutouts disabled +// - pillar_l - a pillar length, used for generating a bridge pillar + +module generate_bridge(what_to_generate = 0, + bridge_angle = 14, + slope_radius = 100, + straight_part_l = 205, + cutout = true, + pillar_l = 50) { + + // Computing all horizontal and vertical offsets + y_off_bg = sin(bridge_angle)*slope_radius + cos(bridge_angle)*slope_straight_plug_length; + y_off_sl = sin(bridge_angle)*slope_radius + cos(bridge_angle)*slope_straight_nest_length; + h_off_bg = slope_radius*(1-cos(bridge_angle)) + sin(bridge_angle) * slope_straight_plug_length; + y_off_straight = cos(bridge_angle) * straight_part_l; + + if (what_to_generate == 0 || what_to_generate == 1) + track_bridge_ground(radius = slope_radius, + angle = bridge_angle, + cutout = cutout); + + if (what_to_generate == 0) + color("LawnGreen") + translate([track_width, y_off_bg, h_off_bg]) + rotate([0, -bridge_angle, 90]) + track(straight_part_l, + cutout = cutout); + + if (what_to_generate == 0 || what_to_generate == 2) + translate([track_width, y_off_bg + y_off_straight + y_off_sl, 0]) + rotate([0, 0, 180]) + track_bridge_slope(radius = slope_radius, + angle = bridge_angle, + cutout = cutout, + slope_part_length = straight_part_l); + + if (what_to_generate == 0 || what_to_generate == 3) + translate([0, y_off_bg + y_off_straight + y_off_sl + 100, 0]) + track_bridge_straight(length = pillar_l, + radius = slope_radius, + angle = bridge_angle, + cutout = cutout, + slope_part_length = straight_part_l); + + // Bridge height + sl_h = slope_radius - (cos(bridge_angle) * slope_radius); + sl_str_h = sin(bridge_angle) * (straight_part_l + + slope_straight_nest_length + + slope_straight_plug_length); + bridge_height = 2 * sl_h + sl_str_h; + + if (what_to_generate == 0) + translate([0, y_off_bg + y_off_straight + y_off_sl + 50, bridge_height/6]) + rotate([0, -90, 0]) + color("OrangeRed") + text(str(round(bridge_height), " mm"), size = bridge_height/6); + echo("Bridge height (distance between ground and bottom layer of the top bridge track): ", str(bridge_height)); +} + +// BRIDGE GROUND PART - called by generate_bridge() +// Input parameters: +// - angle - an angle used to generate the "slope" part of +// the bridge ground part +// - radius - radius used when generating the "slope" part +// - cutout - used for material saving, it cuts out the middle part +// from a track body if the track path is of sufficient length: +// -- true - the module will try to make a cutout +// -- false - cutouts disabled + +module track_bridge_ground(radius = 200, angle = 20, cutout = true) { + + sl_h = radius - (cos(angle) * radius); + sl_dis = sin(angle) * radius; + + difference() { + union() { + rotate([0, 90, 0]) + translate([-radius, 0, 0]) { + rotate_extrude(angle = angle, convexity = 10) + translate([radius, 0 , 0]) + rotate([0, 0, 90]) + track_blueprint(); + + // Low end (nest) + translate([radius, 0, 0]) + rotate([90, 0, -90]) + track(length = slope_straight_nest_length, + cutout = false, + end1 = "none", + end2 = "nest", + part_chamfers = true, + end1_chamfers = false, + end2_chamfers = true, + grooves = true, + both_sides = false); + + // High end (plug) + rotate([0, 0, angle]) + translate([radius, slope_straight_plug_length, 0]) { + rotate([90, 0, -90]) + track(length = slope_straight_plug_length, + cutout = false, + end1 = "plug", + end2 = "none", + part_chamfers = true, + end1_chamfers = true, + end2_chamfers = false, + grooves = true, + both_sides = false); + + + translate([0, -slope_straight_plug_length, track_width-track_chamfer]) + rotate([0, 90, 0]) + cube([track_width - 2 * track_chamfer, + slope_straight_plug_length + track_plug_neck_length - track_plug_radius, + 2.5]); + } + } + } //union + + if (cutout) { + if (true) { // TO DO: work out a rule + hull() { + translate([track_width/2, tr_co_w, 0]) + cylinder(d = tr_co_w, h = 2*sl_h); + translate([track_width/2, sl_dis - tr_co_w, 0]) + cylinder(d = tr_co_w, h = 2*sl_h); + } + } //if (minimum length) + } //if (cutout) + } //difference + + + // Pillar + translate([track_chamfer, sl_dis-slope_straight_plug_length/2, 0]) { + cube([track_width - 2*track_chamfer, bridge_pillar_depth, sl_h]); + } +} + +// BRIDGE UPPER SLOPE PART - called by generate_bridge() +// Input parameters: +// - angle - an angle used to generate the "slope" part of +// the bridge upper slope part +// - radius - radius used when generating the "slope" part +// - cutout - used for material saving, it cuts out the middle part +// from a track body if the track path is of sufficient length: +// -- true - the module will try to make a cutout +// -- false - cutouts disabled +// - straight_part_l - straight track length which will be connecting +// bridge ground and upper parts, this is needed in order to +// calculate overall bridge height and to present an overview + +module track_bridge_slope(radius = 200, angle = 20, cutout = true, slope_part_length = 146) { + + sl_h = radius - (cos(angle) * radius); + sl_dis = sin(angle) * radius; + sl_str_h = sin(angle) * (slope_part_length + + slope_straight_nest_length + + slope_straight_plug_length); + sl_pilr_corr_h = sin(angle) * bridge_pillar_depth; + + translate([0, 0, sl_str_h + 2 * sl_h]) + difference() { + union() { + rotate([0, -90, 0]) + translate([-radius, 0, -track_width]) + { + rotate_extrude(angle = angle, convexity = 10) + translate([radius, 0 , 0]) + rotate([180, 0, 90]) + track_blueprint(); + + // High ending (nest) + translate([radius, 0, track_width]) + rotate([-90, 0, -90]) + track(slope_straight_nest_length, + cutout = false, + end1 = "none", + end2 = "nest", + part_chamfers = true, + end1_chamfers = false, + end2_chamfers = true, + grooves = true, + both_sides = false); + + translate([radius, -slope_straight_nest_length-track_nest_neck_length, track_chamfer]) + rotate([0, -90, 0]) + cube([track_width - 2 * track_chamfer, + slope_straight_nest_length + track_nest_neck_length, + 2.5]); + + // Low ending (nest) + rotate([0, 0, angle]) + translate([radius, slope_straight_nest_length, track_width]) { + rotate([-90, 0, -90]) + track(slope_straight_nest_length, + cutout = false, + end1 = "nest", + end2 = "none", + part_chamfers = true, + end1_chamfers = true, + end2_chamfers = false, + grooves = true, + both_sides = false); + translate([-2.5, -slope_straight_nest_length, -track_chamfer]) + rotate([0, 90, 0]) + cube([track_width - 2 * track_chamfer, + slope_straight_nest_length + track_nest_neck_length, + 2.5]); + } + } + } //union + + if (cutout) { + if (true) { //length condition to be added + hull() { + translate([track_width/2, 1.5 * tr_co_w, -sl_h]) + cylinder(d = tr_co_w, h = 2*sl_h); + translate([track_width/2, sl_dis - 1.5 * tr_co_w, -sl_h]) + cylinder(d = tr_co_w, h = 2*sl_h); + } + } //if (minimum length) + } //if (cutout) + } //difference + + // Higher pillar + translate([track_chamfer, -slope_straight_nest_length, 0]) { + cube([track_width - 2*track_chamfer, bridge_pillar_depth, 2 * sl_h + sl_str_h]); + } + + // Lower pillar + translate([track_chamfer, sl_dis-bridge_pillar_depth, 0]) { + cube([track_width - 2*track_chamfer, bridge_pillar_depth, sl_h + sl_str_h+sl_pilr_corr_h]); + } + // Pillars connecting bar + translate([track_chamfer, -slope_straight_nest_length, 0]) + cube([track_width - 2*track_chamfer, sl_dis+slope_straight_nest_length, 3]); + /* + // Bridge height + echo("Bridge height (distance between ground and bottom layer of the top bridge track): ", str(2 * sl_h + sl_str_h)); + */ +} + +// BRIDGE PILLAR - called by generate_bridge() +// Input parameters: +// - length - length of the straight pillar track +// - angle - an angle used to generate the "slope" parts of +// the bridge components +// - radius - radius used for generation of the "slope" bridge parts +// - cutout - used for material saving, it cuts out the middle part +// from a track body if the track path is of sufficient length: +// -- true - the module will try to make a cutout +// -- false - cutouts disabled +// - straight_part_l - straight track length which will be connecting +// bridge ground and upper parts, this is needed in order to +// calculate overall bridge height and to present an overview + +module track_bridge_straight(length = 50, radius = 200, angle = 20, cutout = true, slope_part_length = 146) { + + sl_h = radius - (cos(angle) * radius); + sl_dis = sin(angle) * radius; + sl_str_h = sin(angle) * (slope_part_length + + slope_straight_nest_length + + slope_straight_plug_length); + sl_pilr_corr_h = sin(angle) * bridge_pillar_depth; + + translate([0, 0, sl_str_h + 2 * sl_h]) + rotate([0, 0, 90]) + translate([0, -track_width, 0]) { + + // Straight track part + track(length = length, cutout = cutout); + + // Nest side + translate([track_nest_radius + track_plug_neck_length + track_chamfer, track_chamfer, -2.5]) + rotate([0, 0, 90]) + cube([track_width - 2 * track_chamfer, + 2* track_nest_radius + track_nest_neck_length + + track_chamfer, + 2.5]); + + // Plug side + translate([length + track_plug_neck_length-track_plug_radius, track_chamfer, -2.5]) + rotate([0, 0, 90]) + cube([track_width - 2 * track_chamfer, + bridge_pillar_depth + + track_plug_neck_length - + track_plug_radius, + 2.5]); + } + + // First pillar + translate([track_chamfer, -track_nest_radius, 0]) { + cube([track_width - 2*track_chamfer, bridge_pillar_depth, 2 * sl_h + sl_str_h]); + } + + // Second pillar + translate([track_chamfer, length - bridge_pillar_depth, 0]) { + cube([track_width - 2*track_chamfer, bridge_pillar_depth, 2 * sl_h + sl_str_h]); + } + // Pillars connecting bar + translate([track_chamfer, -track_nest_radius, 0]) + cube([track_width - 2*track_chamfer, length + track_nest_radius, 3]); +} + +// ### DOG-BONE GENERATOR MODULE +// This module generates a special part which connects two adjacent +// nests. The part is named after its shape. +// A dog-bone consists of two connected plugs. The plugs have +// a little bit oversized heads (when compared to standard plugs) and +// they usually give snug fit. You will need to use some force to +// disconnect two parts (nests) connected with use of a dog-bone part. +// Input parameters: +// - track_plug_radius_ext - radius of a desired oversized plug head + +module track_dogbone(radius = track_plug_radius_ext) { + cut = 1.25; + shortenby = 0.4; + offset = track_plug_neck_width/2 + cut/2; + difference() { + union() { + translate([-shortenby, 0, 0]) + track_plug(radius); + translate([shortenby, 0, 0]) + rotate([0, 0, 180]) + track_plug(radius); + } + for(i=[offset, -offset]) { + hull() { + translate([track_plug_neck_length+radius/3, i, -cc])cylinder(d=cut, h = track_height+2*cc); + translate([-track_plug_neck_length-+radius/3, i, -cc])cylinder(d=cut, h = track_height+2*cc); + } + } + } +} + + +// Module track_plug() is used by multiple other modules for generating +// a plug at a desired track ending +module track_plug(radius = track_plug_radius, + neck_w = track_plug_neck_width, + neck_l = track_plug_neck_length){ + + translate([neck_l, 0, 0]) + rotate([0, 0, 180]) { + rotate_extrude(angle = 360) { + polygon([[0, 0], + [radius - track_chamfer/2, 0], + [radius, track_chamfer/2], + [radius, track_height - track_chamfer/2], + [radius - track_chamfer/2, track_height], + [0, track_height]]); + } + translate([0, -neck_w/2, 0]) + cube([neck_l, neck_w, track_height]); + } +} + +// Module track_nest() is used by multiple other modules for generating +// a nest at a desired track ending +module track_nest(radius = track_nest_radius, + neck_w = track_nest_neck_width, + neck_l = track_nest_neck_length) { + translate([neck_l-cc, 0, 0]) + rotate([0, 0, 180]) { + rotate_extrude(angle = 360) { + #polygon([[0, -cc], + [radius + track_chamfer/2, -cc], + [radius, track_chamfer/2], + [radius, track_height - track_chamfer/2], + [radius + track_chamfer/2, track_height + 2*cc], + [0, track_height + 2* cc]]); + } + translate([0, -neck_w/2, -cc]) + difference() { + cube([neck_l, neck_w, track_height + 2*cc]); + translate([0, 0, 0]) { + + } + } + } +} + +// Track shape blueprint +module track_blueprint(grooves = true, both_sides = false, part_chamfers = true) { + + difference() { + + square([track_width, track_height]); + + // Corner champers + if (part_chamfers) { + part_chamfers(); + } + // Rail grooves + if (grooves) + if (both_sides) grooves(true); + else grooves(false); + } //difference +} + +// Helping module to add chamfers to the track blueprint +module part_chamfers() { + for (x = [ 0, track_width]) { + for (y = [ 0, track_height]) { + translate([x, y, 0]) + rotate([0, 0, 45]) + square(track_chamfer, center = true); + } + } +} + +// Helping module for chamfer shape +module chamfer() { + rotate([0, 0, 45]) + square(track_chamfer, center = true); +} + +// Helping module used for generation of track grooves +module grooves(both_sides = false) { + for (x = [-track_well_spacing/2, track_well_spacing/2]) { + translate([track_width/2+x, track_height-track_well_depth+cc, 0]) + rail_well(); + if (both_sides) { + translate([track_width/2+x, track_well_depth-cc, 0]) + rotate([0, 0, 180]) rail_well(); + } + } +} + +// Helping function which defines a grove shape +module rail_well() { + translate([-track_well_width_bottom/2, 0, 0]) + polygon([[0, 0], + [track_well_width_bottom, 0], + [track_well_width_bottom+tr_wl_w_stick_out, track_well_depth], + [-tr_wl_w_stick_out, track_well_depth]]); +} + +// ### ADAPTER GENERATOR MODULE +// This module generates an adapter between two different track systems. +// It is possible to generate adapters of various length between IKEA +// and BRIO or between one of them and another systems which dimensions +// are defined in the general dimension variables at the beginning +// of this file. +// Input parameters: +// - length - desired length of an adapter +// - nest - nest system, "B" for BRIO, "I" for IKEA, else for a system +// defined in the general dimension variables (track_plug_xxxxxxxx) +// - plug - plug system, "B" for BRIO, "I" for IKEA, else for a system +// defined in the general dimension variables (track_nest_xxxxxxxx) + +module tracks_adapter(length = 30, nest = "B", plug = "I") { + + txt_size_p = 9; + txt_size_n = 8; + + a_nest_radius = (nest == "B") ? brio_track_nest_radius + : (nest == "I") + ? ikea_track_nest_radius + : track_nest_radius; + + a_nest_neck_w = (nest == "B") ? brio_track_nest_neck_width + : (nest == "I") + ? ikea_track_nest_neck_width + : track_nest_neck_width; + a_nest_neck_l = (nest == "B") ? brio_track_nest_neck_length + : (nest == "I") + ? ikea_track_nest_neck_length + : track_nest_neck_length; + a_nest_letter = (nest == "B") ? brio_track_nest_txt + : (nest == "I") + ? ikea_track_nest_txt + : track_nest_txt; + + a_plug_radius = (plug == "B") ? brio_track_plug_radius + : (plug == "I") + ? ikea_track_plug_radius + : track_plug_radius; + + a_plug_neck_w = (plug == "B") ? brio_track_plug_neck_width + : (plug == "I") + ? ikea_track_plug_neck_width + : track_plug_neck_width; + a_plug_neck_l = (plug == "B") ? brio_track_plug_neck_length + : (plug == "I") + ? ikea_track_plug_neck_length + : track_plug_neck_length; + a_plug_letter = (plug == "B") ? brio_track_plug_txt + : (plug == "I") + ? ikea_track_plug_txt + : track_plug_txt; + + difference() { + rotate([90, 0, 90]) + linear_extrude(length) + track_blueprint(grooves = true, + part_chamfers = true, + both_sides = false); + + // Generating Nest + translate([0, track_width/2, 0]) + track_nest(radius = a_nest_radius, + neck_w = a_nest_neck_w, + neck_l = a_nest_neck_l); + translate([0, track_width/2, track_height/2 - cc]) + rotate([0, 0, 45]) + cube([a_nest_neck_w, + a_nest_neck_w, + track_height + 2*cc], + center = true); + + // Corner chamfers + for (y = [ 0, track_width]) { + translate([0, y, track_height/2]) + rotate([0, 0, 45]) + cube([track_chamfer, + track_chamfer, + track_height], + center = true); + } + + for (y = [ 0, track_width]) { + translate([length, y, track_height/2]) + rotate([0, 0, 45]) + cube([track_chamfer, + track_chamfer, + track_height], + center = true); + } + + // Nest letter + translate([a_nest_neck_l+a_nest_radius+2, + track_width/2-a_nest_neck_w/2-txt_size_n/1.7, + track_height-1.4]) { + linear_extrude(1.5) text(a_nest_letter, + font = "Courier New:style=Bold", + size = txt_size_n); + } + } //main difference + + // Generating Plug + difference() { + translate([length, track_width/2, 0]) + track_plug(radius = a_plug_radius, + neck_w = a_plug_neck_w, + neck_l = a_plug_neck_l); + // Plug letter + translate([length + a_plug_neck_l-txt_size_p/2+1, + track_width/2-txt_size_p/2+0.75, + track_height-1.4]) { + linear_extrude(1.5) text(a_plug_letter, + font = "Courier New:style=Bold", + size = txt_size_p); + } + } +} \ No newline at end of file diff --git a/static/holder.css b/static/holder.css index 7047afc..ecc647e 100644 --- a/static/holder.css +++ b/static/holder.css @@ -166,6 +166,13 @@ #fit-verdict.fit-ok { color: #4ad97a; font-weight: 600; } #fit-verdict.fit-bad { color: var(--danger, #d94a4a); font-weight: 600; } +/* Tracks page: part-type selector (single big dropdown) */ +.part-select { + width: 100%; + font-size: 13px; + padding: 5px 8px; +} + /* SCAD editor — slides up from the bottom of the viewport */ .scad-toggle { position: absolute; diff --git a/static/holder.html b/static/holder.html index 57d45f8..72a570a 100644 --- a/static/holder.html +++ b/static/holder.html @@ -26,6 +26,7 @@ diff --git a/static/index.html b/static/index.html index d7a2718..1ccbd91 100644 --- a/static/index.html +++ b/static/index.html @@ -13,6 +13,7 @@ diff --git a/static/js/tracks-app.js b/static/js/tracks-app.js new file mode 100644 index 0000000..2963b75 --- /dev/null +++ b/static/js/tracks-app.js @@ -0,0 +1,216 @@ +/* tracks-app.js — controller for the /tracks (train tracks generator) page. + * + * - GET /api/tracks/params — schema for all parts. + * - Switching the part selector rebuilds the param form for that part. + * - POST /api/tracks/render with {part, params} → STL → Three.js viewer. + * - Re-uses holder-viewer.js (Three.js scene) and the same status / progress + * markup the holder uses, styled via holder.css. + */ + +const $ = (id) => document.getElementById(id); + +const state = { + schema: null, // {parts: [{key, label, module, params, defaults}, ...]} + partsByKey: {}, // shortcut: key -> spec + currentPart: null, // key + params: {}, // per-part current values (replaced on part change) + rendering: false, + abortCtrl: null, + elapsedTimer: null, + lastBlob: null, + rendererReady: false, +}; + +function _whenViewerReady(cb) { + if (window.HolderViewer) { cb(); return; } + let tries = 0; + const t = setInterval(() => { + if (window.HolderViewer) { clearInterval(t); cb(); } + else if (++tries > 50) { clearInterval(t); console.error("HolderViewer never loaded"); } + }, 60); +} + +// ----- form rendering ------------------------------------------------------- + +function _renderForm() { + const spec = state.partsByKey[state.currentPart]; + const root = $("param-form"); + root.innerHTML = ""; + if (!spec) return; + + for (const p of spec.params) { + const row = document.createElement("div"); + row.className = "param-row"; + + const label = document.createElement("label"); + label.textContent = p.label || p.name; + row.appendChild(label); + + let inp; + if (p.kind === "select") { + inp = document.createElement("select"); + for (const opt of p.options || []) { + const o = document.createElement("option"); + o.value = opt; o.textContent = opt; + if (opt === state.params[p.name]) o.selected = true; + inp.appendChild(o); + } + } else if (p.kind === "bool") { + inp = document.createElement("input"); + inp.type = "checkbox"; + inp.checked = !!state.params[p.name]; + } else { + inp = document.createElement("input"); + inp.type = "number"; + if (p.min != null) inp.min = p.min; + if (p.max != null) inp.max = p.max; + if (p.step != null) inp.step = p.step; + inp.value = state.params[p.name] ?? p.default; + } + inp.name = p.name; + inp.dataset.kind = p.kind; + inp.addEventListener("change", _onParamChange); + inp.addEventListener("input", _onParamChange); + row.appendChild(inp); + + if (p.help) { + const h = document.createElement("div"); + h.className = "param-help"; + h.textContent = p.help; + row.appendChild(h); + } + root.appendChild(row); + } + + $("status").textContent = spec.label; +} + +function _onParamChange(e) { + const el = e.target; + const k = el.name; + let v; + if (el.dataset.kind === "bool") v = el.checked; + else if (el.dataset.kind === "number") v = el.value === "" ? null : Number(el.value); + else v = el.value; + state.params[k] = v; +} + +function _switchPart(key) { + const spec = state.partsByKey[key]; + if (!spec) return; + state.currentPart = key; + state.params = { ...spec.defaults }; + $("part-help").textContent = spec.module ? `module: ${spec.module}()` : ""; + _renderForm(); +} + +// ----- render orchestration ------------------------------------------------- + +function _showProgress(show) { + $("render-progress").hidden = !show; + $("btn-render").hidden = show; + $("btn-cancel").hidden = !show; +} + +async function _doRender() { + if (state.rendering || !state.currentPart) return; + state.rendering = true; + state.abortCtrl = new AbortController(); + $("render-status").textContent = "rendering…"; + $("warning").textContent = ""; + _showProgress(true); + + const t0 = performance.now(); + state.elapsedTimer = setInterval(() => { + $("render-elapsed").textContent = ((performance.now() - t0) / 1000).toFixed(1) + "s"; + }, 100); + + try { + const res = await fetch("/api/tracks/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ part: state.currentPart, params: state.params }), + signal: state.abortCtrl.signal, + }); + if (!res.ok) { + let msg = res.statusText; + try { msg = (await res.json()).error || msg; } catch {} + throw new Error(msg); + } + state.lastBlob = await res.blob(); + const buf = await state.lastBlob.arrayBuffer(); + window.HolderViewer.loadSTL(buf); + const dt = ((performance.now() - t0) / 1000).toFixed(1); + $("render-status").textContent = + `✓ rendered in ${dt}s · ${(state.lastBlob.size/1024).toFixed(0)} kB`; + } catch (e) { + if (e.name === "AbortError") $("render-status").textContent = "✗ cancelled"; + else $("render-status").textContent = `✗ ${e.message}`; + } finally { + state.rendering = false; + state.abortCtrl = null; + clearInterval(state.elapsedTimer); + _showProgress(false); + } +} + +// ----- wiring --------------------------------------------------------------- + +$("btn-render").addEventListener("click", _doRender); +$("btn-cancel").addEventListener("click", () => state.abortCtrl?.abort()); + +$("part-select").addEventListener("change", (e) => _switchPart(e.target.value)); + +$("btn-download-stl").addEventListener("click", () => { + if (!state.lastBlob) { alert("Nothing rendered yet."); return; } + const url = URL.createObjectURL(state.lastBlob); + const a = document.createElement("a"); + a.href = url; + a.download = `track_${state.currentPart}.stl`; + document.body.appendChild(a); a.click(); + setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); +}); + +// Ctrl+Enter triggers Render. +document.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + _doRender(); + } +}); + +// ----- bootstrap ------------------------------------------------------------ + +async function init() { + try { + const r = await fetch("/api/tracks/params"); + if (!r.ok) throw new Error(r.statusText); + state.schema = await r.json(); + state.partsByKey = Object.fromEntries(state.schema.parts.map((p) => [p.key, p])); + + // Populate the part selector. + const sel = $("part-select"); + sel.innerHTML = ""; + for (const p of state.schema.parts) { + const o = document.createElement("option"); + o.value = p.key; o.textContent = p.label; + sel.appendChild(o); + } + // Pick "track" (straight) by default — most useful first impression. + const defaultKey = state.partsByKey.track ? "track" : state.schema.parts[0].key; + sel.value = defaultKey; + _switchPart(defaultKey); + } catch (e) { + $("param-form").innerHTML = + `
Couldn't load schema: ${e.message}
`; + return; + } + + _whenViewerReady(() => { + window.HolderViewer.init($("viewer3d")); + state.rendererReady = true; + _doRender(); // initial render so the user sees something + }); +} + +init(); diff --git a/static/scad.html b/static/scad.html index 5f59af3..059b54c 100644 --- a/static/scad.html +++ b/static/scad.html @@ -26,6 +26,7 @@ diff --git a/static/tracks.html b/static/tracks.html new file mode 100644 index 0000000..e07dc17 --- /dev/null +++ b/static/tracks.html @@ -0,0 +1,76 @@ + + + + + +Train Tracks Generator + + + + + + +
+

Train Tracks

+ + + + choose a part → + +
+ + + +
+
+ +
+ + +
+
+
+
left-drag: rotate · right-drag: pan · wheel: zoom
+
+
+
+ + + + + diff --git a/tracks.py b/tracks.py new file mode 100644 index 0000000..21510a4 --- /dev/null +++ b/tracks.py @@ -0,0 +1,274 @@ +"""Wooden-train track generator (IKEA Lillabo / Brio / J'adore compatible). + +Wraps the train_tracks.scad library from + https://github.com/torwanbukaj/ikea-brio-others-compatible-train-tracks-generator + +The .scad ships as a "library + a single active example call". We comment +the example out locally and dynamically build a tiny wrapper SCAD per +render request: + + include + track(length=100, end1="plug", end2="nest", cutout=true, ...); + +Each "part" the user can generate has its own parameter schema (PART_SCHEMAS). +The frontend renders a form per selected part; the backend builds the call +and reuses holder.render_source() for the actual openscad invocation. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import holder # for render_source() and _to_scad_literal() + +APP_DIR = Path(__file__).resolve().parent +SCAD_LIBRARY = APP_DIR / "scad" / "train_tracks.scad" + + +# --------------------------------------------------------------------------- +# Part schemas. +# "module" — the SCAD module to invoke. +# "params" — UI-driven inputs; each entry is the same shape the /holder +# schema uses (name/label/kind/default/min/max/step/options/help). +# "value_map" — optional name -> {ui_value: scad_value} translation for +# select params whose label isn't what the SCAD wants +# (e.g. bridge "ground" -> int 1). +# --------------------------------------------------------------------------- + +PART_SCHEMAS: dict[str, dict] = { + + # ---------- Calibration ------------------------------------------------- + "tester": { + "label": "Track tester (calibration)", + "module": "track_tester", + "params": [ + {"name": "tester_length", "label": "Tester length (mm)", + "kind": "number", "default": 25, "min": 15, "max": 60, "step": 1, + "help": "Small block — print first to verify plug/nest fit."}, + ], + }, + + # ---------- Straight ---------------------------------------------------- + "track": { + "label": "Straight track", + "module": "track", + "params": [ + {"name": "length", "label": "Length (mm)", "kind": "number", + "default": 100, "min": 20, "max": 500, "step": 5, + "help": "IKEA standards: 50 / 100 / 146 / 205 / 240. Brio: 145."}, + {"name": "end1", "label": "End 1", "kind": "select", + "default": "plug", "options": ["plug", "nest", "none"]}, + {"name": "end2", "label": "End 2", "kind": "select", + "default": "nest", "options": ["plug", "nest", "none"]}, + {"name": "cutout", "label": "Wheel cutout","kind": "bool", "default": True}, + {"name": "grooves", "label": "Side grooves","kind": "bool", "default": True}, + {"name": "part_chamfers", "label": "Corner chamfers", "kind": "bool", "default": True}, + {"name": "both_sides", "label": "Both sides grooves", "kind": "bool", "default": False}, + ], + }, + + # ---------- Arc --------------------------------------------------------- + "arc": { + "label": "Arc / curved track", + "module": "track_arc", + "params": [ + {"name": "radius", "label": "Radius (mm)", "kind": "number", + "default": 185, "min": 50, "max": 500, "step": 1, + "help": "IKEA Lillabo: 180–185. J'adore: 86. Brio U-turn: 86."}, + {"name": "angle", "label": "Angle (deg)", "kind": "number", + "default": 45, "min": -180, "max": 180, "step": 1, + "help": "Negative angle curves the other direction. IKEA: 22.5 or 45."}, + {"name": "end1", "label": "End 1", "kind": "select", + "default": "plug", "options": ["plug", "nest", "none"]}, + {"name": "end2", "label": "End 2", "kind": "select", + "default": "nest", "options": ["plug", "nest", "none"]}, + {"name": "grooves", "kind": "bool", "default": True}, + {"name": "cutout", "kind": "bool", "default": True}, + {"name": "both_sides", "kind": "bool", "default": False}, + {"name": "part_chamfers", "kind": "bool", "default": True}, + ], + }, + + # ---------- Dog-bone connector ----------------------------------------- + "dogbone": { + "label": "Dog-bone (nest–nest connector)", + "module": "track_dogbone", + "params": [ + {"name": "radius", "label": "Plug radius (mm)", "kind": "number", + "default": 6.5, "min": 5.5, "max": 7.5, "step": 0.05, + "help": "IKEA Lillabo / Brio: 6.5 (slightly oversized for springy fit)."}, + ], + }, + + # ---------- Intersection ----------------------------------------------- + "intersection": { + "label": "Intersection (crossing)", + "module": "intersection", + "params": [ + {"name": "angle", "label": "Crossing angle (deg)", "kind": "number", + "default": 90, "min": 30, "max": 150, "step": 1}, + {"name": "lengthA", "label": "Length A (mm)", "kind": "number", + "default": 100, "min": 50, "max": 300, "step": 5}, + {"name": "lengthB", "label": "Length B (mm)", "kind": "number", + "default": 100, "min": 50, "max": 300, "step": 5}, + {"name": "end1A", "kind": "select", "default": "nest", "options": ["plug", "nest", "none"]}, + {"name": "end2A", "kind": "select", "default": "plug", "options": ["plug", "nest", "none"]}, + {"name": "end1B", "kind": "select", "default": "nest", "options": ["plug", "nest", "none"]}, + {"name": "end2B", "kind": "select", "default": "plug", "options": ["plug", "nest", "none"]}, + {"name": "both_sides", "kind": "bool", "default": True}, + ], + }, + + # ---------- Switch / turnout ------------------------------------------- + "switch": { + "label": "Switch (turnout)", + "module": "switch", + "params": [ + {"name": "angleR", "label": "Right angle (deg)", "kind": "number", + "default": 45, "min": 10, "max": 90, "step": 1}, + {"name": "radiusR", "label": "Right radius (mm)", "kind": "number", + "default": 86, "min": 50, "max": 300, "step": 1}, + {"name": "endR", "label": "Right end", "kind": "select", + "default": "nest", "options": ["plug", "nest"]}, + {"name": "angleL", "label": "Left angle (deg)", "kind": "number", + "default": 45, "min": 10, "max": 90, "step": 1}, + {"name": "radiusL", "label": "Left radius (mm)", "kind": "number", + "default": 86, "min": 50, "max": 300, "step": 1}, + {"name": "endL", "label": "Left end", "kind": "select", + "default": "plug", "options": ["plug", "nest"]}, + {"name": "lengthS", "label": "Straight length (mm)", "kind": "number", + "default": 146, "min": 50, "max": 300, "step": 1}, + {"name": "endS", "label": "Straight end", "kind": "select", + "default": "nest", "options": ["plug", "nest"]}, + {"name": "endCommon", "label": "Common (junction) end", "kind": "select", + "default": "nest", "options": ["plug", "nest"]}, + {"name": "both_sides", "kind": "bool", "default": True}, + ], + }, + + # ---------- Snake track ------------------------------------------------ + "snake": { + "label": "Snake (S-curve)", + "module": "snake_track", + "params": [ + {"name": "angle", "kind": "number", "default": 45, "min": 10, "max": 90, "step": 1}, + {"name": "radius", "kind": "number", "default": 86, "min": 50, "max": 300, "step": 1}, + {"name": "target_length", "label": "Target length (mm)", "kind": "number", + "default": 200, "min": 50, "max": 400, "step": 5, + "help": "Must be larger than the S-curve's natural Y-span (about 150mm for angle=45, radius=86)."}, + {"name": "end1", "kind": "select", "default": "plug", "options": ["plug", "nest", "none"]}, + {"name": "end2", "kind": "select", "default": "nest", "options": ["plug", "nest", "none"]}, + {"name": "cutout", "kind": "bool", "default": True}, + {"name": "both_sides", "kind": "bool", "default": True}, + ], + }, + + # ---------- Adapter (BRIO <-> IKEA) ------------------------------------ + "adapter": { + "label": "Adapter (BRIO ↔ IKEA)", + "module": "tracks_adapter", + "params": [ + {"name": "length", "kind": "number", "default": 30, "min": 20, "max": 80, "step": 1}, + {"name": "nest", "label": "Nest side system", "kind": "select", + "default": "B", "options": ["I", "B"], + "help": "I = IKEA Lillabo, B = Brio."}, + {"name": "plug", "label": "Plug side system", "kind": "select", + "default": "I", "options": ["I", "B"]}, + ], + }, + + # ---------- Bridge ----------------------------------------------------- + "bridge": { + "label": "Bridge (multi-part)", + "module": "generate_bridge", + "params": [ + {"name": "what_to_generate", "label": "Part", "kind": "select", + "default": "ground", + "options": ["overview", "ground", "slope", "pillar"], + "help": "Print each part separately; overview shows them together for sizing."}, + {"name": "bridge_angle", "label": "Slope angle (deg)", "kind": "number", + "default": 14, "min": 5, "max": 30, "step": 1}, + {"name": "slope_radius", "label": "Slope radius (mm)", "kind": "number", + "default": 100, "min": 50, "max": 300, "step": 5}, + {"name": "straight_part_l", "label": "Straight part (mm)", "kind": "number", + "default": 205, "min": 50, "max": 400, "step": 5}, + {"name": "pillar_l", "label": "Pillar length (mm)", "kind": "number", + "default": 50, "min": 20, "max": 200, "step": 5}, + {"name": "cutout", "kind": "bool", "default": True}, + ], + "value_map": { + # SCAD takes int 0..3; UI uses labels. + "what_to_generate": {"overview": 0, "ground": 1, "slope": 2, "pillar": 3}, + }, + }, +} + + +def schema_dict() -> dict: + """Public API shape returned by /api/tracks/params.""" + parts = [] + for key, spec in PART_SCHEMAS.items(): + defaults = {p["name"]: p["default"] for p in spec["params"]} + parts.append({ + "key": key, + "label": spec["label"], + "module": spec["module"], + "params": spec["params"], + "defaults": defaults, + }) + return {"parts": parts} + + +# --------------------------------------------------------------------------- +# Wrapper-SCAD builder +# --------------------------------------------------------------------------- + +def _to_scad_literal(v: Any) -> str: + # Defer to holder's literal formatter for consistency (bools lowercased, + # ints stay int-like, strings get JSON-quoted). + return holder._to_scad_literal(v) + + +def build_wrapper_scad(part: str, params: dict | None) -> str: + spec = PART_SCHEMAS.get(part) + if not spec: + raise ValueError(f"unknown track part: {part!r}") + value_map = spec.get("value_map", {}) + schema_by_name = {p["name"]: p for p in spec["params"]} + + args: list[str] = [] + incoming = params or {} + for p in spec["params"]: + name = p["name"] + if name in incoming and incoming[name] is not None: + v = incoming[name] + else: + v = p["default"] + # Coerce + if p["kind"] == "number": + v = float(v) + if v.is_integer(): + v = int(v) + elif p["kind"] == "bool": + v = bool(v) + elif p["kind"] == "select": + v = str(v) + # Apply optional label -> SCAD value translation + if name in value_map and v in value_map[name]: + v = value_map[name][v] + args.append(f"{name} = {_to_scad_literal(v)}") + + # Path relative to the temp file holder.render_source writes — it puts + # the file in a fresh tempdir, so resolve absolute path for `include`. + return ( + f'include <{SCAD_LIBRARY.as_posix()}>\n' + f'{spec["module"]}({", ".join(args)});\n' + ) + + +def render(part: str, params: dict | None = None) -> tuple[bytes, dict]: + """Render the chosen track part with the supplied params.""" + src = build_wrapper_scad(part, params) + return holder.render_source(src)