Last active
June 9, 2023 07:24
-
-
Save DreadBoy/5eebc3c088ff58253494cabc2a76c222 to your computer and use it in GitHub Desktop.
Mecabricks - import all bricks in set
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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