LDraw.org Discussion Forums
Idea for LUA script to create a knolling layout - Printable Version

+- LDraw.org Discussion Forums (https://forums.ldraw.org)
+-- Forum: LDraw Programs (https://forums.ldraw.org/forum-7.html)
+--- Forum: LDraw Editors and Viewers (https://forums.ldraw.org/forum-11.html)
+--- Thread: Idea for LUA script to create a knolling layout (/thread-28450.html)



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:

  1. 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.
  2. 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"
  3. Then, we will want to sort the bricks based on size and color (or other order?)
  4. 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.
  5. 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
  6. 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
  7. Then do the next row or column and so on
  8. 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 Smile


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  Confused

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  Confused

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 Smile

[Image: attachment.php?aid=11961]

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)?