Skip to content

Instantly share code, notes, and snippets.

@DreadBoy
Last active June 9, 2023 07:24
Show Gist options
  • Save DreadBoy/5eebc3c088ff58253494cabc2a76c222 to your computer and use it in GitHub Desktop.
Save DreadBoy/5eebc3c088ff58253494cabc2a76c222 to your computer and use it in GitHub Desktop.
Mecabricks - import all bricks in set
// ==UserScript==
// @name Mecabricks - import all bricks in set
// @namespace http://mecabricks.com
// @version 0.4
// @description Import all bricks from official set and lay them out.
// @author Dread_Boy
// @match https://www.mecabricks.com/en/workshop*
// @icon https://www.google.com/s2/favicons?sz=64&domain=mecabricks.com
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
const colors = [{id: 0, lego: 26},{id: 1, lego: 23},{id: 2, lego: 28},{id: 3, lego: 107},{id: 4, lego: 21},{id: 5, lego: 221},{id: 6, lego: 217},{id: 7, lego: 2},{id: 8, lego: 27},{id: 9, lego: 45},{id: 10, lego: 37},{id: 11, lego: 116},{id: 12, lego: 101},{id: 13, lego: 9},{id: 14, lego: 24},{id: 15, lego: 1},{id: 17, lego: 6},{id: 18, lego: 3},{id: 19, lego: 5},{id: 20, lego: 39},{id: 21, lego: 50},{id: 22, lego: 104},{id: 23, lego: 196},{id: 25, lego: 106},{id: 26, lego: 124},{id: 27, lego: 119},{id: 28, lego: 138},{id: 29, lego: 222},{id: 30, lego: 324},{id: 31, lego: 325},{id: 32, lego: 109},{id: 33, lego: 43},{id: 34, lego: 48},{id: 35, lego: 311},{id: 36, lego: 41},{id: 40, lego: 111},{id: 41, lego: 42},{id: 42, lego: 49},{id: 43, lego: 229},{id: 45, lego: 113},{id: 46, lego: 44},{id: 47, lego: 40},{id: 52, lego: 126},{id: 54, lego: 157},{id: 57, lego: 47},{id: 68, lego: 36},{id: 69, lego: 198},{id: 70, lego: 192},{id: 71, lego: 194},{id: 72, lego: 199},{id: 73, lego: 102},{id: 74, lego: 29},{id: 75, lego: 75},{id: 76, lego: 304},{id: 77, lego: 223},{id: 78, lego: 283},{id: 79, lego: 20},{id: 80, lego: 336},{id: 82, lego: 335},{id: 84, lego: 312},{id: 85, lego: 268},{id: 86, lego: 217},{id: 89, lego: 195},{id: 92, lego: 18},{id: 100, lego: 100},{id: 110, lego: 110},{id: 112, lego: 112},{id: 114, lego: 114},{id: 115, lego: 115},{id: 117, lego: 117},{id: 118, lego: 118},{id: 120, lego: 120},{id: 125, lego: 121},{id: 129, lego: 129},{id: 132, lego: 304},{id: 133, lego: 305},{id: 134, lego: 139},{id: 135, lego: 131},{id: 137, lego: 145},{id: 142, lego: 127},{id: 143, lego: 143},{id: 148, lego: 148},{id: 150, lego: 150},{id: 151, lego: 208},{id: 158, lego: 326},{id: 178, lego: 147},{id: 179, lego: 315},{id: 182, lego: 182},{id: 183, lego: 183},{id: 191, lego: 191},{id: 212, lego: 212},{id: 226, lego: 226},{id: 230, lego: 230},{id: 232, lego: 232},{id: 236, lego: 284},{id: 272, lego: 140},{id: 288, lego: 141},{id: 294, lego: 294},{id: 297, lego: 297},{id: 308, lego: 308},{id: 320, lego: 154},{id: 321, lego: 321},{id: 322, lego: 322},{id: 323, lego: 323},{id: 326, lego: 330},{id: 334, lego: 310},{id: 335, lego: 153},{id: 351, lego: 22},{id: 366, lego: 12},{id: 373, lego: 136},{id: 378, lego: 151},{id: 379, lego: 135},{id: 383, lego: 309},{id: 450, lego: 4},{id: 462, lego: 105},{id: 484, lego: 38},{id: 503, lego: 103},{id: 1000, lego: 329},{id: 1001, lego: 219},{id: 1002, lego: 339},{id: 1003, lego: 302},{id: 1004, lego: 231},{id: 1005, lego: 234},{id: 1006, lego: 293},{id: 1007, lego: 218},{id: 1012, lego: 19},{id: 1050, lego: 353},{id: 1051, lego: 11},{id: 1052, lego: 341},{id: 1053, lego: 362},{id: 1054, lego: 364},{id: 1055, lego: 360},{id: 1056, lego: 363},{id: 1057, lego: 227},{id: 1058, lego: 285},{id: 1059, lego: 365},{id: 1060, lego: 367},{id: 1061, lego: 366},{id: 1062, lego: 368},{id: 1063, lego: 346},{id: 1064, lego: 13},{id: 1065, lego: 189},{id: 1066, lego: 180},{id: 1067, lego: 128},{id: 1068, lego: 123},{id: 1069, lego: 184},{id: 1070, lego: 185},{id: 1071, lego: 186},{id: 1072, lego: 187},{id: 1073, lego: 149},{id: 1074, lego: 188},{id: 1075, lego: 269},{id: 1076, lego: 15},{id: 1077, lego: 14},{id: 1078, lego: 210},{id: 1079, lego: 233},{id: 1080, lego: 224},{id: 1081, lego: 216},{id: 1082, lego: 295},{id: 1083, lego: 176},{id: 1084, lego: 178},{id: 1085, lego: 179},{id: 1086, lego: 200},{id: 1087, lego: 16},{id: 1088, lego: 370},{id: 1089, lego: 371},{id: 1092, lego: 300},{id: 1093, lego: 220},{id: 1094, lego: 236},{id: 1095, lego: 375}];
function hexToRgb(hex) {
var bigint = parseInt(hex, 16);
var r = (bigint >> 16) & 255;
var g = (bigint >> 8) & 255;
var b = bigint & 255;
return "rgb(" + r + ", " + g + ", " + b + ")";
}
function waitForElm(selector) {
return new Promise(resolve => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver(mutations => {
if (document.querySelector(selector)) {
resolve(document.querySelector(selector));
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}
let materials = null;
async function getMaterials() {
if(materials) return materials;
const res = await $.ajax({
url: "https://www.mecabricks.com/api/materials",
type: 'POST',
converters: {"text html": $.parseJSON}
});
materials = res.data;
return materials;
}
let parts = null;
function save(_parts) {
parts = _parts;
localStorage.setItem("parts", JSON.stringify(parts));
}
function restore() {
const data = localStorage.getItem("parts");
parts = !data ? null : JSON.parse(data);
return parts;
}
function reset() {
localStorage.removeItem("parts");
parts = null;
}
async function queryPartPage(query, page, filters = '{"decorated":false,"parts":true,"assemblies":false}') {
const formData = new FormData();
formData.append("query", query);
formData.append("page", page);
formData.append("filters", filters);
formData.append("lang", "en");
const res = await $.ajax({
url: "https://www.mecabricks.com/api/workshop/part-library/search",
data: {
query,
page,
filters,
lang: "en"
},
type: 'POST',
converters: {"text html": $.parseJSON}
});
return {next: res.data.next, parts: res.data.parts};
}
/**
* @param {{quantity: string, color: string, partId: string}} parts
*/
async function validatePartList(parts) {
const results = [];
for(let part of parts) {
if(!part.partId) {
results.push({...part, validPart: false});
continue;
}
const {parts: result} = await queryPartPage(part.partId, 1);
if(!result.find(r => r.extra.reference == part.partId)){
results.push({...part, validPart: false});
continue;
}
results.push({...part, validPart: true});
};
return results;
}
/**
* @param {{quantity: string, color: string, partId: string}} parts
*/
function addCoordinates(parts) {
const size = Math.ceil(Math.sqrt(parts.length));
return parts.map((part, index) => {
const x = (index % size) * 48;
const z = Math.floor(index / size) * 48;
return {...part, x, y: 0, z};
});
}
/**
* @param {{quantity: string, color: string, partId: string}} parts
*/
async function addMaterials(parts) {
const groups = await getMaterials();
const withMaterials = parts.map(({color: id, ...part}) => {
let reference = colors.find(c => c.id == +id);
if(!reference) {
return {...part, color: {id}, validColor: false};
}
reference = reference.lego;
const group = groups.find(group => group.materials.find(mat => mat.reference == reference));
if(!group) {
return {...part, color: {id}, validColor: false};
}
const color = group.materials.find(mat => mat.reference == reference);
const rgb = hexToRgb(color.rgb);
return {
color: {id, group: group.name, style: rgb},
validColor: true,
...part
}
}).filter(Boolean);
return withMaterials;
}
async function simplifyParts(parts) {
const results = [];
for(let part of parts) {
if(!part.partId || part.validPart) {
results.push(part);
continue;
}
const {parts: result} = await queryPartPage(part.partId, 1);
const found = result.find(r => part.partId.includes(r.extra.reference));
if(!found) {
results.push(part);
continue;
}
results.push({...part, partId: found.extra.reference, validPart: true});
};
return results;
}
function createTable() {
function row(part) {
const checked = part.validPart && part.validColor && !part.placed;
const row = $(`<table><tbody><tr data-part-id="${part.partId}"></tr></tbody></table>`).find("tr");
row.append(`<td><input type="checkbox" class="need-to-place"${checked ? " checked" : ""}></td>`);
row.append(`<td${!part.validPart ? ' style="color:red"' : ''}>${part.partId}</td>`);
row.append(`<td>${part.quantity}</td>`);
row.append(`<td style="${part.validColor ? `background:${part.color.style}` : 'color:red'}">${part.color.id}</td>`);
row.append(`<td>${part.x},${part.y},${part.z}</td>`);
row.append(`<td><input type="checkbox" class="is-placed" disabled${part.placed ? " checked" : ""}></td>`);
return row.prop('outerHTML');
}
const table = $(`<div class="table" style="display:grid;grid-template-columns:400px 150px;user-select: text;"><table><thead><th><input type="checkbox"></th><th>Part ID</th><th>Quantity</th><th>Color</th><th>Coordinates</th><th>Is placed</th></thead><tbody>${parts.map(row).join("")}</tbody></table>
<div><button class="ui-button-wrapper db-button">Clear</button><button class="ui-button-wrapper db-button">Place parts</button><h5>Tool to manage parts</h5><button class="ui-button-wrapper db-button">Simplify parts</button></div>
</div>`);
table.find("thead input").change(function () {
table.find("tr input.need-to-place").each((index, checkbox) => $(checkbox).prop("checked", this.checked));
});
table.find("button").eq(0).on("click", () => {
reset();
removeTable();
});
table.find("button").eq(1).on("click", async () => {
table.find("button").eq(1).prop('disabled', true).addClass("ui-disabled");
const inputs = table.find('tbody input.need-to-place:checked').parents("tr").map((i, tr) => $(tr).data("partId").toString()).get();
const needToPlace = parts.filter(part => inputs.indexOf(part.partId) > -1);
const result = await placeParts(needToPlace);
const merged = parts.map(part => result.find(r => r.partId == part.partId) || part);
updateTable(merged);
save(merged);
table.find("button").eq(1).prop('disabled', false).removeClass("ui-disabled");
});
table.find("button").eq(2).on("click", async function () {
$(this).prop('disabled', true).addClass("ui-disabled");
const simplified = await simplifyParts(parts);
updateTable(simplified);
save(simplified);
$(this).prop('disabled', false).removeClass("ui-disabled");
});
$(".import-dialog").append(table);
$(".paste-inventory").hide();
}
function updateTable(parts) {
const table = $(".import-dialog .table");
for(let part of parts) {
const checked = part.validPart && part.validColor && !part.placed;
const row = table.find(`tr[data-part-id="${part.partId}"]`);
row.find(".need-to-place").prop("checked", checked);
row.find(".is-placed").prop("checked", part.placed);
}
}
function removeTable() {
$(".import-dialog .table").remove();
$(".paste-inventory").show();
}
/**
* @param {{quantity: string, color: {group: string, style: string}, x: number, z: number, y: number, partId: string}} part
*/
async function placePart(part) {
await new Promise(resolve => setTimeout(resolve, 100));
$("#part-library .items .item").remove();
$(".ui-search-wrapper.search input").val(part.partId).trigger($.Event( 'keypress', { which: 13, code: "Enter", key: "Enter", charCode: 13 } ));
await waitForElm("#part-library .items .item");
const item = $("#part-library .items .item").filter(function() {return $(this).find(".reference").text() == part.partId;}).get(0);
if(!item) {
console.warn("Missing item", part);
return false;
}
$("#materials-header select").val(part.color.group).trigger("change");
await new Promise(resolve => setTimeout(resolve, 100));
const color = $("#materials-overview .item").filter(function() { return this.style.backgroundColor == part.color.style; }).get(0)
if(!color) {
console.warn("Missing color", part);
return false;
}
for(let i = 0; i < part.quantity; i++) {
$(item).click();
await new Promise(resolve => setTimeout(resolve, 100));
$(color).click();
$("#transform-table #transform-loc-x").val(part.x);
$("#transform-table #transform-loc-y").val(i * 16);
$("#transform-table #transform-loc-z").val(part.z);
$("#transform-table #transform-rot-x").val(0);
$("#transform-apply").click();
}
return true;
}
async function placeParts(parts) {
$("#panels-tab-transform").click();
$(".ui-panel-wrapper.ui-position-single.filter").trigger("mousedown");
$(".filter-panel .ui-checkbox-input").prop("checked", false).trigger("change");
$(".filter-panel .ui-checkbox-input").eq(1).prop("checked", true).trigger("change");
$(".ui-panel-wrapper.ui-position-single.filter").trigger("mousedown");
const results = [];
for(let part of parts) {
const placed = await placePart(part);
results.push({...part, placed});
updateTable([{...part, placed}]);
}
return results;
}
$(() => {
const dialog = $("<div class='import-dialog'></div>").attr("style", "max-width: 70vw;max-height: 50vh;background: rgba(0, 0, 0, 0.4);color: rgb(255, 255, 255);position: fixed;top: 50%;left: 50%;transform: translate(-50%, -50%);border-radius: 3px;padding:4px;overflow: auto;").hide();
$(document.body).append(dialog);
$("nav ul.left").append("<li><a>Import set</a></li>").click(() => {
dialog.toggle();
});
/*
const form = $("<form><label>Set ID<input name='setId' value='76389-1' /></label><button>Search</button></form>").on("submit", (e) => {
e.preventDefault();
const setId = new FormData(e.target).get("setId");
const url = `https://www.brickowl.com/search/catalog?query=${setId}&cat=3`;
window.open(url, '_blank').focus();
});
*/
const partsForm = $('<form class="paste-inventory"><label>Upload .csv file, downloaded from Rebrickable<br><input name="inventory" type="file"/></label><button class="ui-button-wrapper db-button">Process</button></form>');
partsForm.on("submit", async (e) => {
e.preventDefault();
const value = new FormData(e.target).get("inventory");
if(!value || !value.size) return;
const csv = await value.text();
const data = csv
.split("\n")
.slice(1)
.map(row => {
const [partId, color, quantity] = row.replace("\r", "").split(",");
return {partId, color, quantity};
})
.filter(({partId}) => partId);
$(".paste-inventory .ui-button-wrapper").prop('disabled', true).addClass("ui-disabled");
const parts = addCoordinates(await addMaterials(await validatePartList(data)));
save(parts);
createTable();
$(partsForm).find("input").val("");
$(".paste-inventory .ui-button-wrapper").prop('disabled', false).removeClass("ui-disabled");
});
dialog.append(partsForm);
const restored = restore();
if(restored) {
createTable(restored);
}
dialog.toggle();
GM_addStyle('.db-button { color: white; padding: 2px 4px; }');
})
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment