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