Idea for LUA script to create a knolling layout - Jaco van der Molen - 2024-10-11
Hi all,
A question came up about creating a nice and satisfying knolled layout for a model.
This can now be done in LDPub3D, but the output is either a PDF page or PNG images showing the knolled BOM of a model.
If we want this in an LDraw file, we could be able to do this in a LUA script in LDCad.
There are a few sample scripts that take the bricks of a model and reorder them.
I was thinking about what if we can make something like the Selection Circle script does.
I am slowly learning (self taught) LUA and am trying to modify the Selection Circle myself.
But this is complex.
Maybe a LUA wizard or programmer can shine a light on this?
The steps involved, I think, would be something like this:
- Select all bricks in the model. This should be done in Nested mode to select all bricks in submodels too. Deleting all metacommands other then the header and bricks (for example steps, LPub commands, etc.) The result is a nice stack of all bricks in just one big model.
- Next we should reset all parts to plane level. So every brick that has a rotation should be reset to default straight rotation level on the y-plane (I hope I explain this correct) and maybe put all parts at the 0,0,0, coordinate. This can already be done with the provide sample script "Copy orientation"
- Then, we will want to sort the bricks based on size and color (or other order?)
- Next, starting with the smallest brick on the 0,0,0 position, rows and columns of the other bricks should be made to get a square or rectangular shape for the layout. You should be able to set the amount of rows or columns or calculate the best ratio for your model.
- After the first brick, the next should be positioned at say 20 LDU from the previous brick (distance between bricks would be 1 stud) either to form a row or column
- Then the next and the next and so on until the first X amount of bricks are neatly positioned and aligned in a row or column
- Then do the next row or column and so on
- A collision check should be done to make sure no bricks overlap
Am I on the right track? Is it too complex or can it be done in other ways?
LMKWYT
RE: Idea for LUA script to create a knolling layout - Fredrik Hareide - 2024-10-11
Hi! I think this is a really interesting idea. I would love to help make this at some point but I do not have a lot of time on my hands right now. I did however try our latest helper (AI) to see if we could get a working prototype to work from. It actually worked fairly well.
This script can layout all selected parts but it does not do it in nested mode and it does not reset rotation. With some work that should be possible to change. I must admit that for small stuff like this AI is pretty cool.
Code: -- Function to get the X, Y (min), and Z dimensions of a subfile using its bounding box
function getSubfileDimensions(subfile)
local minVec = subfile:getBoundingBoxMin()
local maxVec = subfile:getBoundingBoxMax()
return maxVec:getX() - minVec:getX(), minVec:getY(), maxVec:getZ() - minVec:getZ() -- Return width, minY, and depth
end
-- Function to sort subfiles by their combined XY size
function sortSubfilesBySizeXY(a, b)
local widthA, depthA = getSubfileDimensions(a)
local widthB, depthB = getSubfileDimensions(b)
local sizeA = widthA * depthA
local sizeB = widthB * depthB
return sizeA < sizeB
end
-- Function to layout parts in a knolling grid arrangement on the XY plane
function knollingLayout()
local ses = ldc.session()
if not ses:isLinked() then
ldc.dialog.runMessage('No active model.')
return
end
local sel = ses:getSelection()
local cnt = sel:getRefCount()
if cnt < 2 then
ldc.dialog.runMessage('At least two items should be selected.')
return
end
local subfiles = {}
-- Collect all subfiles (Parts in the selection)
for i = 1, cnt do
table.insert(subfiles, sel:getRef(i))
end
-- Sort subfiles by size (combined X and Z dimensions)
table.sort(subfiles, function(a, b)
return sortSubfilesBySizeXY(a:getSubfile(), b:getSubfile())
end)
-- Determine grid dimensions and spacing
local gridSize = 20 -- Minimum grid size, consider part size + desired spacing
local xOffset = 0
local zOffset = 0
local rowWidth = 0
for i, part in ipairs(subfiles) do
local subfile = part:getSubfile()
local width, minY, depth = getSubfileDimensions(subfile) -- X and Z dimensions, and minY for the Y position
-- Set the part's position
local pos = ldc.vector(xOffset, minY, zOffset)
part:setPos(pos)
-- Update offset for next part
zOffset = zOffset + depth + gridSize -- Move right to the next column
-- Keep track of the maximum width in the current row
rowWidth= math.max(rowWidth, width + gridSize)
-- Check if we need to start a new row
if zOffset + depth > gridSize * 100 then -- Assuming grid is 10 columns wide
xOffset = xOffset + rowWidth
zOffset = 0 -- Move down to the next row
rowWidth = 0 -- Reset row width for the new row
end
end
end
-- Register the macro for the knolling layout
function register()
local macro_lo = ldc.macro('Perform knolling layout')
macro_lo:setHint('Arrange elements in a knolling layout based on size in XY plane with Z = 0.')
macro_lo:setEvent('run', 'knollingLayout')
end
register()
RE: Idea for LUA script to create a knolling layout - Fredrik Hareide - 2024-10-11
5541 result. Not great but it's doing something
RE: Idea for LUA script to create a knolling layout - Fredrik Hareide - 2024-10-11
I made some adjustments to the script to reset rotation and prepare for inlining. I'm not sure how to inline in LDCad script so here I might need Roland.
But if you inline before running it it's starting to look like something
Code: -- Function to get the X, Y (min), and Z dimensions of a subfile using its bounding box
function getSubfileDimensions(subfile)
local minVec = subfile:getBoundingBoxMin()
local maxVec = subfile:getBoundingBoxMax()
return maxVec:getX() - minVec:getX(), minVec:getY(), maxVec:getZ() - minVec:getZ() -- Return width, minY, and depth
end
-- Function to sort subfiles by their combined XZ size
function sortSubfilesBySizeXZ(a, b)
local widthA, _, depthA = getSubfileDimensions(a)
local widthB, _, depthB = getSubfileDimensions(b)
local sizeA = widthA * depthA
local sizeB = widthB * depthB
return sizeA < sizeB
end
-- Function to reset the rotation of a part to identity
function resetPartRotation(part)
local identityMatrix = ldc.matrix()
identityMatrix:setIdentity()
part:setOri(identityMatrix)
end
-- Function to check if a part is a submodel and print its name if it is
function checkAndPrintSubmodel(part)
local subfile = part:getSubfile()
if subfile:isModel() and subfile:getRefCount() > 0 then
-- print("Submodel name: " .. subfile:getFileName())
-- Do stuff here to inline the submodels
end
end
-- Function to layout parts in a knolling grid arrangement on the XY plane
function knollingLayout()
local ses = ldc.session()
if not ses:isLinked() then
ldc.dialog.runMessage('No active model.')
return
end
local sel = ses:getSelection()
local cnt = sel:getRefCount()
if cnt < 2 then
ldc.dialog.runMessage('At least two items should be selected.')
return
end
local subfiles = {}
-- Collect all subfiles (Parts in the selection)
for i = 1, cnt do
local part = sel:getRef(i)
table.insert(subfiles, part)
checkAndPrintSubmodel(part) -- Check and print submodel name if applicable
end
-- Sort subfiles by size (combined X and Z dimensions)
table.sort(subfiles, function(a, b)
return sortSubfilesBySizeXZ(a:getSubfile(), b:getSubfile())
end)
-- Determine grid dimensions and spacing
local gridSize = 20 -- Minimum grid size, consider part size + desired spacing
local xOffset = 0
local zOffset = 0
local rowWidth = 0
for i, part in ipairs(subfiles) do
local subfile = part:getSubfile()
local width, minY, depth = getSubfileDimensions(subfile) -- X and Z dimensions, and minY for the Y position
-- Reset the part's rotation
resetPartRotation(part)
-- Set the part's position
local pos = ldc.vector(xOffset, minY, zOffset)
part:setPos(pos)
-- Update offset for next part
zOffset = zOffset + depth + gridSize -- Move right to the next column
-- Keep track of the maximum width in the current row
rowWidth = math.max(rowWidth, width + gridSize)
-- Check if we need to start a new row
if zOffset + depth > gridSize * 100 then -- Assuming the grid is 10 columns wide
xOffset = xOffset + rowWidth
zOffset = 0 -- Move down to the next row
rowWidth = 0 -- Reset row width for the new row
end
end
end
-- Register the macro for the knolling layout
function register()
local macro_lo = ldc.macro('Perform knolling layout')
macro_lo:setHint('Arrange elements in a knolling layout based on size in XY plane with Z = 0.')
macro_lo:setEvent('run', 'knollingLayout')
end
register()
RE: Idea for LUA script to create a knolling layout - Jaco van der Molen - 2024-10-11
Nice!
I get an error though:
Code: [string "knolling.lua"]:3: attempt to call a nil value (method 'getBoundingBoxMin')
(I saved this scipt knolling.lua)
RE: Idea for LUA script to create a knolling layout - Philippe Hurbain - 2024-10-11
(2024-10-11, 7:48)Jaco van der Molen Wrote: Nice!
I get an error though:
Code: [string "knolling.lua"]:3: attempt to call a nil value (method 'getBoundingBoxMin')
(I saved this scipt knolling.lua)
Do you use 1.7 beta 1?
Worked fine here...
The "bugs" I noticed are some overlaps (rounded corner plates) and weird height (antennas, heads). Or is is a problem in the bounding box function?
RE: Idea for LUA script to create a knolling layout - Fredrik Hareide - 2024-10-11
Yep, I also see the overlapping bugs. Haven't started rewriting the entire code manually yet. So might be an issue with the bb handling yes. I think every row and column need to find out the widest and deepest element and make that the amount to move. Or maybe it's better to avoid the grid logic and just pack everything more dense. That might be better
RE: Idea for LUA script to create a knolling layout - Jaco van der Molen - 2024-10-11
(2024-10-11, 8:10)Philippe Hurbain Wrote: Do you use 1.7 beta 1?
Worked fine here...
Oops, my bad. I work on more PC's / Laptops and noticed I used 1.6d
In 1.7b1 works fine and got some nice results too.
To enhance this more, perhaps we can take a look at parts that need to rotate 90 degrees like you would knolling in real life with physical bricks, like wheels and tyres.
I tested with a model of a car ;-)
And what I would love is a sort by part and color!
Now it is "just" the order in which the parts are hierarchical in the LDR file.
I must say it is very nice to see this fun "feature" is picked up so fast!
RE: Idea for LUA script to create a knolling layout - Fredrik Hareide - 2024-10-11
(2024-10-11, 8:37)Jaco van der Molen Wrote: Oops, my bad. I work on more PC's / Laptops and noticed I used 1.6d
In 1.7b1 works fine and got some nice results too.
To enhance this more, perhaps we can take a look at parts that need to rotate 90 degrees like you would knolling in real life with physical bricks, like wheels and tyres.
I tested with a model of a car ;-)
And what I would love is a sort by part and color!
Now it is "just" the order in which the parts are hierarchical in the LDR file.
I must say it is very nice to see this fun "feature" is picked up so fast!
New version, now with sorting by size, then part, then color.
Still needs to make the algorithm rotate the parts so it's lowest profile. So lay down antennas and so on. And also pack it more densly. But it's getting better. Also have some overlaps still
Code: -- Setting to determine layout direction ('width' or 'depth')
local layoutDirection = 'depth' -- Change this to 'width' to layout by width
-- Function to get the X, Y (min), and Z dimensions of a subfile using its bounding box
function getSubfileDimensions(subfile)
local minVec = subfile:getBoundingBoxMin()
local maxVec = subfile:getBoundingBoxMax()
return maxVec:getX() - minVec:getX(), minVec:getY(), maxVec:getZ() - minVec:getZ() -- Return width, minY, and depth
end
-- Function to sort subfiles by their combined XZ size,
-- then by name, and finally by color
function sortSubfiles(a, b)
-- Get dimensions and calculate size
local widthA, _, depthA = getSubfileDimensions(a:getSubfile())
local widthB, _, depthB = getSubfileDimensions(b:getSubfile())
local sizeA = widthA * depthA
local sizeB = widthB * depthB
-- Compare by size first
if sizeA ~= sizeB then
return sizeA < sizeB
end
-- Compare by name if sizes are equal
local nameA = a:getSubfile():getFileName()
local nameB = b:getSubfile():getFileName()
if nameA ~= nameB then
return nameA < nameB
end
-- Compare by color if names are equal
local colorA = a:getColor()
local colorB = b:getColor()
return colorA < colorB
end
-- Function to reset the rotation of a part to identity
function resetPartRotation(part)
local identityMatrix = ldc.matrix()
identityMatrix:setIdentity()
part:setOri(identityMatrix)
end
-- Function to check if a part is a submodel and print its name if it is
function checkAndPrintSubmodel(part)
local subfile = part:getSubfile()
if subfile:isModel() and subfile:getRefCount() > 0 then
-- print("Submodel name: " .. subfile:getFileName())
-- Do stuff here to inline the submodels
end
end
-- Function to layout parts densely based on the layout direction
function knollingLayout()
local ses = ldc.session()
if not ses:isLinked() then
ldc.dialog.runMessage('No active model.')
return
end
local sel = ses:getSelection()
local cnt = sel:getRefCount()
if cnt < 2 then
ldc.dialog.runMessage('At least two items should be selected.')
return
end
local subfiles = {}
-- Collect all subfiles (Parts in the selection)
for i = 1, cnt do
local part = sel:getRef(i)
table.insert(subfiles, part)
checkAndPrintSubmodel(part) -- Check and print submodel name if applicable
end
-- Sort subfiles by size, name, and color
table.sort(subfiles, sortSubfiles)
-- Determine spacing and maximum row/cell size
local spacing = 20
local maxRowSize = 2000 -- Maximum size for a row or column
-- Keep track of the current row's size and maximum dimension height
local currentRowSize = 0
local currentRowMaxDim = 0
local mainOffset = 0
local secondaryOffset = 0
for _, part in ipairs(subfiles) do
local subfile = part:getSubfile()
local width, minY, depth = getSubfileDimensions(subfile)
local primarySize, secondarySize
if layoutDirection == 'width' then
primarySize = width
secondarySize = depth
else
primarySize = depth
secondarySize = width
end
-- Check if adding this part exceeds the current row's size
if currentRowSize + primarySize + spacing > maxRowSize then
-- Move to the next row or column
mainOffset = 0
secondaryOffset = secondaryOffset + currentRowMaxDim + spacing
currentRowSize = 0
currentRowMaxDim = 0
end
-- Reset the part's rotation
resetPartRotation(part)
-- Set the part's position
local pos
if layoutDirection == 'width' then
pos = ldc.vector(mainOffset, minY, secondaryOffset)
else
pos = ldc.vector(secondaryOffset, minY, mainOffset)
end
part:setPos(pos)
-- Update offsets for the next item in the current row or column
mainOffset = mainOffset + primarySize + spacing
currentRowSize = currentRowSize + primarySize + spacing
currentRowMaxDim = math.max(currentRowMaxDim, secondarySize + spacing)
end
-- Pack layout more densely
packLayout(subfiles)
end
-- Function to pack the layout more densely
function packLayout(subfiles)
local spacing = 20
local maxRowSize = 2000
local currentRowSize = 0
local currentRowMaxDim = 0
local mainOffset = 0
local secondaryOffset = 0
for _, part in ipairs(subfiles) do
local subfile = part:getSubfile()
local width, minY, depth = getSubfileDimensions(subfile)
local primarySize, secondarySize
if layoutDirection == 'width' then
primarySize = width
secondarySize = depth
else
primarySize = depth
secondarySize = width
end
-- Check if adding this part exceeds the current row's size
if currentRowSize + primarySize + spacing > maxRowSize then
-- Move to the next row or column
mainOffset = 0
secondaryOffset = secondaryOffset + currentRowMaxDim + spacing
currentRowSize = 0
currentRowMaxDim = 0
end
-- Set the part's new packed position
local pos
if layoutDirection == 'width' then
pos = ldc.vector(mainOffset, minY, secondaryOffset)
else
pos = ldc.vector(secondaryOffset, minY, mainOffset)
end
part:setPos(pos)
-- Update offsets for the next item in the current row or column
mainOffset = mainOffset + primarySize + spacing
currentRowSize = currentRowSize + primarySize + spacing
currentRowMaxDim = math.max(currentRowMaxDim, secondarySize + spacing)
end
end
-- Register the macro for the knolling layout
function register()
local macro_lo = ldc.macro('Perform knolling layout')
macro_lo:setHint('Arrange elements densely with even spacing in the XY plane with Z = 0.')
macro_lo:setEvent('run', 'knollingLayout')
end
register()
RE: Idea for LUA script to create a knolling layout - Jaco van der Molen - 2024-10-11
(2024-10-11, 9:07)Fredrik Hareide Wrote: New version, now with sorting by size, then part, then color.
Still needs to make the algorithm rotate the parts so it's lowest profile. So lay down antennas and so on. And also pack it more densly. But it's getting better. Also have some overlaps still.
Wow! This is nice!
If you can figure something out for antennas, take wheels and tyres into account too?
And there must be more elements for sure that need to lay flat.
But this is impressive allready.
(I think Magnus can work with this too since this seems to suit his needs)
RE: Idea for LUA script to create a knolling layout - Jaco van der Molen - 2024-10-11
Manually rotated elements tyres, wheels, wheel covers, bricks with stud on side, mudguards, hinge brick (bottom and top), curved brick :-)
RE: Idea for LUA script to create a knolling layout - Rene Rechthaler - 2024-10-11
Studio has a "knolling" feature built-in...
if you import a Set via File>Import>Import official LEGO Set you can choose between "in scene">knolling and "as palette">gives you the inventory as building palette including colors and quantities
-> if "in scene, it places all parts on the building floor, sorted by size (orientation isnt always correct)
RE: Idea for LUA script to create a knolling layout - Fredrik Hareide - 2024-10-11
(2024-10-11, 14:00)Rene Rechthaler Wrote: Studio has a "knolling" feature built-in...
if you import a Set via File>Import>Import official LEGO Set you can choose between "in scene">knolling and "as palette">gives you the inventory as building palette including colors and quantities
-> if "in scene, it places all parts on the building floor, sorted by size (orientation isnt always correct)
I know about this feature but can you import arbitrary ldr with stickers for example?
RE: Idea for LUA script to create a knolling layout - Rene Rechthaler - 2024-10-11
you can only import from the official Studio library (but this includes printed and stickered parts, some of which arent even in Ldraw yet)
the new updates of studio open a dialog if there are missing parts, you can link custom parts instead.
RE: Idea for LUA script to create a knolling layout - Eugen - 2024-10-11
(2024-10-11, 10:16)Jaco van der Molen Wrote: Manually rotated elements tyres, wheels, wheel covers, bricks with stud on side, mudguards, hinge brick (bottom and top), curved brick :-)
At least one part mistakenly rotated 90deg anticlockwise (highlighted with yellow circle correct and incorrect rotated parts of the same type)
RE: Idea for LUA script to create a knolling layout - Magnus Forsberg - 2024-10-11
(2024-10-11, 9:07)Fredrik Hareide Wrote: New version, now with sorting by size, then part, then color.
Still needs to make the algorithm rotate the parts so it's lowest profile. So lay down antennas and so on. And also pack it more densly. But it's getting better. Also have some overlaps still
A big thank you, Fredrik
It's uasable, but as you write it could pack things better.
If I use it on a set of stickers, say 15-30 pcs, I get them lined up in a very long row.
It would look better if they are arranged in a rectangle. Like on a full sticker sheet.
RE: Idea for LUA script to create a knolling layout - Eugen - 2024-10-11
(2024-10-11, 14:00)Rene Rechthaler Wrote: Studio has a "knolling" feature built-in...
if you import a Set via File>Import>Import official LEGO Set you can choose between "in scene">knolling and "as palette">gives you the inventory as building palette including colors and quantities
-> if "in scene, it places all parts on the building floor, sorted by size (orientation isnt always correct) (2024-10-11, 16:06)Rene Rechthaler Wrote: you can only import from the official Studio library (but this includes printed and stickered parts, some of which arent even in Ldraw yet)
the new updates of studio open a dialog if there are missing parts, you can link custom parts instead.
LeoCAD also has some basic "knolling" for "File -> Import -> Set Invetory..." (by set ID from Rebrickable). Sadly, there is no option for import inventory from file (even LeoCAD has export inventory feature to BrickLink XML and CSV).
Also, there is "Submodel -> Properties... -> Used Parts" window with a spreadsheet for overview parts counted by type/colour/number used/total (could be done with exported CSV in Excel or any other spreadsheet sotware as well).
Here are few LeoCAD screenshots for the "20020-1 Mini Turbo Shreder" set (not in OMR yet) inventory imported from Rebrickable (https://rebrickable.com/sets/20020-1/mini-turbo-shredder/)
All parts autoplaced, as well as instruction is autogenerated via "File -> Instructions..." (with only small manual tuning for page size/fonts). Instruction widget in LeoCAD is not complete, and has very basic settings yet.
RE: Idea for LUA script to create a knolling layout - Eugen - 2024-10-11
(2024-10-11, 7:05)Fredrik Hareide Wrote: 5541 result. Not great but it's doing something
And here is LeoCAD's result for import 5541-1 set inventory from Rebrickable, for comparison
RE: Idea for LUA script to create a knolling layout - Roland Melkert - 2024-10-15
(2024-10-11, 7:41)Fredrik Hareide Wrote: I made some adjustments to the script to reset rotation and prepare for inlining. I'm not sure how to inline in LDCad script so here I might need Roland.
You could build a flattened list of all used subfiles (parts) with their abs position and other needed info.
This is done recursive on the refs like:
Code: function buildFlatList(parentSf, parentPosOri, list)
local cnt=parentSf:getRefCount()
for i=1, cnt do
local ref=parentSf:getRef(i)
local sf=ref:getSubfile()
local relPosOri=ref:getPosOri()
local absPosOri=relPosOri*parentPosOri
if sf:isPart() then
local item={
pos=absPosOri:getPos(),
sf=sf
}
table.insert(list, item)
--print(item.sf:getFileName() .. ' => ' .. item.pos:__tostring())
else
if sf:isGenerated() then
--collect/map flex info if needed, for now just skip it.
--print('flex: '..sf:getFileName())
else
buildFlatList(sf, absPosOri, list)
end
end
end
end
and use it like:
Code: local list={}
buildFlatList(ldc.subfile(), ldc.matrix(), list)
-- sort/use list etc
Also I think the overlap are caused by not compensating for the parts absolute center. The bounding min max vecs are not symmetrical.
Small tip:
setIdentity is not needed a new ldc.matrix defaults to identity. (technically it is noise until used internally, but from pov of the user it will be id)
RE: Idea for LUA script to create a knolling layout - Fredrik Hareide - 2024-10-19
Still a lot left to do, but this should be better. It now just grabs all parts on the file, nukes the entire file and places them back in order. So all submodels and steps etc. will be removed.
Still need to fix the packing and overlap but no need to do anything to the model anymore. Just hit the button. Also no need to select anything. Will also prompt for target size and direction
Code: function getSubfileDimensions(subfile)
local minVec = subfile:getBoundingBoxMin()
local maxVec = subfile:getBoundingBoxMax()
return maxVec:getX() - minVec:getX(), minVec:getY(), maxVec:getZ() - minVec:getZ() -- Return width, minY, and depth
end
function sortSubfiles(a, b)
local widthA, _, depthA = getSubfileDimensions(a.sf)
local widthB, _, depthB = getSubfileDimensions(b.sf)
local sizeA = widthA * depthA
local sizeB = widthB * depthB
if sizeA ~= sizeB then
return sizeA < sizeB
end
local nameA = a.sf:getFileName()
local nameB = b.sf:getFileName()
if nameA ~= nameB then
return nameA < nameB
end
return a.color < b.color
end
function buildFlatList(parentSf, parentPosOri, list)
local cnt = parentSf:getRefCount()
for i = 1, cnt do
local ref = parentSf:getRef(i)
local sf = ref:getSubfile()
local absPosOri = ref:getPosOri() * parentPosOri
if sf:isPart() then
table.insert(list, {
ref = ref,
pos = absPosOri:getPos(),
color = ref:getColor(),
sf = sf
})
elseif not sf:isGenerated() then
buildFlatList(sf, absPosOri, list)
end
end
end
function deleteAllLines(mainSf)
for i = mainSf:getLineCount(), 1, -1 do
local line = mainSf:getLine(i)
line:delete()
end
end
function knollingLayout()
local ses = ldc.session()
if not ses:isLinked() then
ldc.dialog.runMessage('No active model.')
return
end
-- Ask for the target width in LDU
local maxRowSize = tonumber(ldc.dialog.runInput('Target width in LDU (minimum 40)?', '2000'))
if maxRowSize == nil or maxRowSize < 40 then
return
end
local layoutDirection = ldc.dialog.runInput('Layout direction (width or depth)', 'depth')
if layoutDirection == nil or (layoutDirection ~= 'depth' and layoutDirection ~= 'width') then
return
end
local list = {}
local mainSf = ldc.subfile()
buildFlatList(mainSf, ldc.matrix(), list)
deleteAllLines(mainSf)
table.sort(list, sortSubfiles)
local spacing = 20
local currentRowSize = 0
local currentRowMaxDim = 0
local mainOffset = 0
local secondaryOffset = 0
local newRefs = {}
for _, item in ipairs(list) do
local newRef = mainSf:addNewRef(item.sf:getFileName())
newRef:setColor(item.color)
local width, minY, depth = getSubfileDimensions(item.sf)
local primarySize, secondarySize
if layoutDirection == 'width' then
primarySize = width
secondarySize = depth
else
primarySize = depth
secondarySize = width
end
if currentRowSize + primarySize + spacing > maxRowSize then
mainOffset = 0
secondaryOffset = secondaryOffset + currentRowMaxDim + spacing
currentRowSize = 0
currentRowMaxDim = 0
end
local pos
if layoutDirection == 'width' then
pos = ldc.vector(mainOffset, 0, secondaryOffset)
else
pos = ldc.vector(secondaryOffset, 0, mainOffset)
end
newRef:setPos(pos)
mainOffset = mainOffset + primarySize + spacing
currentRowSize = currentRowSize + primarySize + spacing
currentRowMaxDim = math.max(currentRowMaxDim, secondarySize + spacing)
table.insert(newRefs, newRef)
end
end
-- Register the macro for the knolling layout
function register()
local macro_lo = ldc.macro('Perform knolling layout')
macro_lo:setHint('Arrange elements densely with even spacing in the XY plane with Z = 0.')
macro_lo:setEvent('run', 'knollingLayout')
end
register()
RE: Idea for LUA script to create a knolling layout - Roland Melkert - 2024-10-20
(2024-10-19, 15:22)Fredrik Hareide Wrote: Still a lot left to do, but this should be better.
My 2cts:
Instead of deleting the current file you could (optionally) generate a string of new LDraw code and place it on the clipboard.
It also gave me the idea to add something to the api to query the open session list in order to let a user choose a target (eg a new model).
RE: Idea for LUA script to create a knolling layout - Fredrik Hareide - 2024-10-23
I would be great to have a function in the api to create a new file in a new session. Until that I think I will just keep the overwriting logic. Here is another version. Getting better and better. Now with some filters to rotate some parts.
Code: function getSubfileDimensions(subfile)
local minVec = subfile:getBoundingBoxMin()
local maxVec = subfile:getBoundingBoxMax()
local width = maxVec:getX() - minVec:getX()
local height = maxVec:getY() - minVec:getY()
local depth = maxVec:getZ() - minVec:getZ()
return width, height, depth, minVec:getX(), minVec:getY(), minVec:getZ(), maxVec:getX(), maxVec:getY(),
maxVec:getZ()
end
function startsWith(str, start)
return string.sub(str, 1, string.len(start)) == start
end
function startsWithAny(str, substrings)
for _, substr in ipairs(substrings) do
if startsWith(str, substr) then
return true
end
end
return false
end
function containsAllWords(str, words)
for _, word in ipairs(words) do
if not string.find(str, word, 1, true) then -- Using plain search
return false
end
end
return true
end
function rotateToSmallestSideUpIfCircle(width, height, depth, minX, minY, minZ, maxX, maxY, maxZ, newRef)
local desc = newRef:getSubfile():getDescription()
local newRefPosOri = ldc.matrix()
local isRotated = false
if startsWithAny(desc, {"Wheel", "Tyre"}) or containsAllWords(desc, {"Technic", "Gear", "Tooth"}) or
containsAllWords(desc, {"Technic", "Wedge", "Belt", "Wheel"}) or startsWith(desc, "Technic Bush") or
startsWith(desc, "Technic Pulley") then
newRefPosOri:setRotate(90, ldc.vector(1, 0, 0))
newRef:setPosOri(newRefPosOri)
-- Swap depth and height
width, height, depth = width, depth, height
minX, minY, minZ, maxX, maxY, maxZ = minX, minZ, minY, maxX, maxZ, maxY
isRotated = true
elseif depth > width then
newRefPosOri:setRotate(90, ldc.vector(0, 1, 0))
newRef:setPosOri(newRefPosOri)
-- Swap depth and width
width, height, depth = depth, height, width
minX, minY, minZ, maxX, maxY, maxZ = minZ, minY, minX, maxZ, maxY, maxX
isRotated = true
end
return width, height, depth, minX, minY, minZ, maxX, maxY, maxZ, isRotated
end
function sortSubfiles(a, b, threshold)
local widthA, heightA, depthA, minXA, minYA, minZA, maxXA, maxYA, maxZA = getSubfileDimensions(a.sf)
local widthB, heightB, depthB, minXB, minYB, minZB, maxXB, maxYB, maxZB = getSubfileDimensions(b.sf)
-- Rotate to smallest side up
widthA, heightA, depthA, minXA, minYA, minZA, maxXA, maxYA, maxZA =
rotateToSmallestSideUpIfCircle(widthA, heightA, depthA, minXA, minYA, minZA, maxXA, maxYA, maxZA, a.ref)
widthB, depthB, heightB, minXB, minYB, minZB, maxXB, maxYB, maxZB =
rotateToSmallestSideUpIfCircle(widthB, heightB, depthB, minXB, minYB, minZB, maxXB, maxYB, maxZB, b.ref)
-- Determine largest sides after rotation
local largestA = math.max(widthA, heightA, depthA)
local largestB = math.max(widthB, heightB, depthB)
if largestA ~= largestB then
return largestA < largestB
end
-- Fallback to comparing by name if largest sides are equal or thresholds are not met
local nameA = a.sf:getFileName()
local nameB = b.sf:getFileName()
if nameA ~= nameB then
return nameA < nameB
end
-- Finally compare by color if names are also equal
return a.color < b.color
end
function buildFlatList(parentSf, parentPosOri, list)
local cnt = parentSf:getRefCount()
for i = 1, cnt do
local ref = parentSf:getRef(i)
local sf = ref:getSubfile()
local absPosOri = ref:getPosOri() * parentPosOri
if sf:isPart() or sf:isShortCut() then
table.insert(list, {
ref = ref,
color = ref:getColor(),
sf = sf
})
else
if sf:isGenerated() then
-- collect/map flex info if needed, for now just skip it.
-- print('flex: '..sf:getFileName())
else
buildFlatList(sf, absPosOri, list)
end
end
end
end
function deleteAllLines(mainSf)
for i = mainSf:getLineCount(), 1, -1 do
local line = mainSf:getLine(i)
line:delete()
end
end
function zeroOrValue(val1, val2)
local sum = val1 + val2
if sum == 0 then
return 0
else
return sum
end
end
function knollingLayout()
local ses = ldc.session()
if not ses:isLinked() then
ldc.dialog.runMessage('No active model.')
return
end
local maxRowSize = tonumber(ldc.dialog.runInput('Target width in LDU (minimum 40)?', '2000'))
if maxRowSize == nil or maxRowSize < 40 then
return
end
local layoutDirection = ldc.dialog.runInput('Layout direction (width or depth)', 'depth')
if layoutDirection == nil or (layoutDirection ~= 'depth' and layoutDirection ~= 'width') then
return
end
local list = {}
local mainSf = ldc.subfile()
buildFlatList(mainSf, ldc.matrix(), list)
local threshold = 50 -- Set this to your desired threshold value
table.sort(list, function(a, b)
return sortSubfiles(a, b, threshold)
end)
deleteAllLines(mainSf)
local spacing = 20 -- Adjust spacing if needed
local currentRowSize = 0
local currentRowMaxDim = 0
local mainOffset = 0
local secondaryOffset = 0
local newRefs = {}
local pivotOffsetZ = 0
for _, item in ipairs(list) do
local isRotated = false
local newRef = mainSf:addNewRef(item.sf:getFileName())
newRef:setColor(item.color)
local width, height, depth, minX, minY, minZ, maxX, maxY, maxZ = getSubfileDimensions(item.sf)
-- Rotate to smallest side up if the other two sides are equal and get the new center coordinates
width, height, depth, minX, minY, minZ, maxX, maxY, maxZ, isRotated =
rotateToSmallestSideUpIfCircle(width, height, depth, minX, minY, minZ, maxX, maxY, maxZ, newRef)
local primarySize, secondarySize, primaryMin, secondaryMin
if layoutDirection == 'width' then
primarySize = width
secondarySize = depth
primaryMin = minX
secondaryMin = minZ
else
primarySize = depth
secondarySize = width
primaryMin = minZ
secondaryMin = minX
end
if currentRowSize + primarySize + spacing > maxRowSize then
mainOffset = 0
secondaryOffset = secondaryOffset + currentRowMaxDim + spacing
currentRowSize = 0
currentRowMaxDim = 0
lastMax = 0
end
local yPos = 0
if isRotated and maxY ~= 0 then
yPos = math.min(minY, -maxY)
else
yPos = -maxY
end
local pos
if layoutDirection == 'width' then
pos = ldc.vector(mainOffset - primaryMin + spacing, yPos, secondaryOffset - secondaryMin + spacing)
else
pos = ldc.vector(secondaryOffset - secondaryMin + spacing, yPos, mainOffset - primaryMin + spacing)
end
newRef:setPos(pos)
mainOffset = mainOffset + primarySize + spacing
currentRowSize = currentRowSize + primarySize + spacing
currentRowMaxDim = math.max(currentRowMaxDim, secondarySize + spacing)
table.insert(newRefs, newRef)
end
end
-- Register the macro for the knolling layout
function register()
local macro_lo = ldc.macro('Perform knolling layout')
macro_lo:setHint('Arrange elements densely with even spacing in the XY plane with Z = 0.')
macro_lo:setEvent('run', 'knollingLayout')
end
register()
RE: Idea for LUA script to create a knolling layout - N. W. Perry - 2024-11-14
This could be handy when previewing an LDraw model for use in Studio—spotting missing parts, wrong origins/orientation, etc.
What about adding the option to include each part only once (a visual bill of materials, in other words)?
|