Idea for LUA script to create a knolling layout


Idea for LUA script to create a knolling layout
#1
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
Jaco van der Molen
lpub.binarybricks.nl
Reply
RE: Idea for LUA script to create a knolling layout
#2
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()
Reply
RE: Idea for LUA script to create a knolling layout
#3
5541 result. Not great but it's doing something Smile


Attached Files Thumbnail(s)
   
Reply
RE: Idea for LUA script to create a knolling layout
#4
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()


Attached Files Thumbnail(s)
   
Reply
RE: Idea for LUA script to create a knolling layout
#5
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)
Jaco van der Molen
lpub.binarybricks.nl
Reply
RE: Idea for LUA script to create a knolling layout
#6
(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?


Attached Files Thumbnail(s)
   
Reply
RE: Idea for LUA script to create a knolling layout
#7
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
Reply
RE: Idea for LUA script to create a knolling layout
#8
(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!
Jaco van der Molen
lpub.binarybricks.nl
Reply
RE: Idea for LUA script to create a knolling layout
#9
(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()


Attached Files Thumbnail(s)
   
Reply
RE: Idea for LUA script to create a knolling layout
#10
(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)
Jaco van der Molen
lpub.binarybricks.nl
Reply
RE: Idea for LUA script to create a knolling layout
#11
   

Manually rotated elements tyres, wheels, wheel covers, bricks with stud on side, mudguards, hinge brick (bottom and top), curved brick :-)
Jaco van der Molen
lpub.binarybricks.nl
Reply
RE: Idea for LUA script to create a knolling layout
#12
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)
Reply
RE: Idea for LUA script to create a knolling layout
#13
(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?
Reply
RE: Idea for LUA script to create a knolling layout
#14
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.
Reply
RE: Idea for LUA script to create a knolling layout
#15
(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)

   
Reply
RE: Idea for LUA script to create a knolling layout
#16
(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.
Reply
RE: Idea for LUA script to create a knolling layout
#17
(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/min...-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.


Attached Files Thumbnail(s)
                   
Reply
RE: Idea for LUA script to create a knolling layout
#18
(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


Attached Files Thumbnail(s)
       
Reply
RE: Idea for LUA script to create a knolling layout
#19
(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)
Reply
« Next Oldest | Next Newest »



Forum Jump:


Users browsing this thread: 14 Guest(s)