Skip to content

Commit

Permalink
Spritesheet Autoslicer (#893)
Browse files Browse the repository at this point in the history
* Added autoSlicer

* Fixed the remaining things (Autoslicer fully functional)

* Update RegionUnpacker.gd

* Formatting

* formatting

* formatting

* neatify

* Update RegionUnpacker.gd

* formatting

* formatting

* Update RegionUnpacker.gd

* Update README.md

* Added region unpacker class

* Optimized the region unpacker, this should now be twice as fast

addon version: f01526e50db98eea6d4d69db3c241d360887af7f

* change Smart Slicer to 5745b26a6e0b7e10bc4a46d07b5f9f0dd8f26c96

Variable-Interactive/SmartSlicer@5745b26

* Delete addons/SmartSlicer/Shader directory

* removed shader

* Update SmartSlicer Version

278b1c5a80b2c8b89279e405156d556732ce98d2

* Formatting (This is torture LOL)

2578b74ba84289aa109ae715b4a6c90fd5e23126

* Delete addons/SmartSlicer/addons/SmartSlicer/Classes directory

* Formatting

Version remains same

* Delete SmartSlicePreview.gd

* use _draw instead of line2d

and moved SmartSlicerPreview.gd for better organization

* Formatting

* More formatting

* Fix bugs related to import

* fix crash on attempting to open empty image

as new spritesheet tab (smart)

* removed accidental print

* fix empty image warnings
  • Loading branch information
Variable-ind committed Aug 6, 2023
1 parent 42196cd commit 4242859
Show file tree
Hide file tree
Showing 8 changed files with 667 additions and 115 deletions.
7 changes: 7 additions & 0 deletions addons/README.md
Expand Up @@ -27,3 +27,10 @@ Files extracted from source:
- Version: Based on git commit e5df60ed1d53246e03dba36053ff009846ba5174 with a modification on dockable_container.gd (lines 187-191).
- License: [CC0-1.0](https://github.com/gilzoide/godot-dockable-container/blob/main/LICENSE)

## SmartSlicer

- Upstream: https://github.com/Variable-Interactive/SmartSlicer
- Version: Based on git commit 2804e6109f9667022c66522ce88a99a56fd67ca8 with a modification on SmartSlicePreview.gd (lines 31-32). Only the contents of addons folder are used and the script SmartSlicePreview.gd is moved to res://src/UI/Dialogs/HelperScripts/ for better organization
- License: [MIT](https://github.com/Variable-Interactive/SmartSlicer/blob/main/LICENSE)


251 changes: 251 additions & 0 deletions addons/SmartSlicer/Classes/RegionUnpacker.gd
@@ -0,0 +1,251 @@
class_name RegionUnpacker
extends Reference

# THIS CLASS TAKES INSPIRATION FROM PIXELORAMA'S FLOOD FILL
# AND HAS BEEN MODIFIED FOR OPTIMIZATION

var slice_thread := Thread.new()

var _include_boundary_threshold: int # the size of rect below which merging accounts for boundaty
var _merge_dist: int # after crossing threshold the smaller image will merge with larger image
# if it is within the _merge_dist

# working array used as buffer for segments while flooding
var _allegro_flood_segments: Array
# results array per image while flooding
var _allegro_image_segments: Array


func _init(threshold: int, merge_dist: int) -> void:
_include_boundary_threshold = threshold
_merge_dist = merge_dist


func get_used_rects(image: Image) -> Dictionary:
if OS.get_name() == "HTML5":
return get_rects(image)
else:
# If Thread model is set to "Multi-Threaded" in project settings>threads>thread model
if slice_thread.is_active():
slice_thread.wait_to_finish()
var error = slice_thread.start(self, "get_rects", image)
if error == OK:
return slice_thread.wait_to_finish()
else:
return get_rects(image)
# If Thread model is set to "Single-Safe" in project settings>threads>thread model then
# comment the above code and uncomment below
#return get_rects({"image": image})


func get_rects(image: Image) -> Dictionary:
# make a smaller image to make the loop shorter
var used_rect = image.get_used_rect()
if used_rect.size == Vector2.ZERO:
return clean_rects([])
var test_image = image.get_rect(used_rect)
# prepare a bitmap to keep track of previous places
var scanned_area := BitMap.new()
scanned_area.create(test_image.get_size())
test_image.lock()
# Scan the image
var rects = []
var frame_size = Vector2.ZERO
for y in test_image.get_size().y:
for x in test_image.get_size().x:
var position = Vector2(x, y)
if test_image.get_pixelv(position).a > 0: # used portion of image detected
if !scanned_area.get_bit(position):
var rect := _estimate_rect(test_image, position)
scanned_area.set_bit_rect(rect, true)
rect.position += used_rect.position
rects.append(rect)
test_image.unlock()
var rects_info = clean_rects(rects)
rects_info["rects"].sort_custom(self, "sort_rects")
return rects_info


func clean_rects(rects: Array) -> Dictionary:
var frame_size = Vector2.ZERO
for i in rects.size():
var target: Rect2 = rects.pop_front()
var test_rect = target
if (
target.size.x < _include_boundary_threshold
or target.size.y < _include_boundary_threshold
):
test_rect.size += Vector2(_merge_dist, _merge_dist)
test_rect.position -= Vector2(_merge_dist, _merge_dist) / 2
var merged = false
for rect_i in rects.size():
if test_rect.intersects(rects[rect_i]):
rects[rect_i] = target.merge(rects[rect_i])
merged = true
break
if !merged:
rects.append(target)

# calculation for a suitable frame size
if target.size.x > frame_size.x:
frame_size.x = target.size.x
if target.size.y > frame_size.y:
frame_size.y = target.size.y
return {"rects": rects, "frame_size": frame_size}


func sort_rects(rect_a: Rect2, rect_b: Rect2) -> bool:
# After many failed attempts, this version works for some reason (it's best not to disturb it)
if rect_a.end.y < rect_b.position.y:
return true
if rect_a.position.x < rect_b.position.x:
# if both lie in the same row
var start = rect_a.position
var size = Vector2(rect_b.end.x, rect_a.end.y)
if Rect2(start, size).intersects(rect_b):
return true
return false


func _estimate_rect(image: Image, position: Vector2) -> Rect2:
var cel_image := Image.new()
cel_image.copy_from(image)
cel_image.lock()
var small_rect: Rect2 = _flood_fill(position, cel_image)
cel_image.unlock()
return small_rect


# Add a new segment to the array
func _add_new_segment(y: int = 0) -> void:
var segment = {}
segment.flooding = false
segment.todo_above = false
segment.todo_below = false
segment.left_position = -5 # anything less than -1 is ok
segment.right_position = -5
segment.y = y
segment.next = 0
_allegro_flood_segments.append(segment)


# fill an horizontal segment around the specified position, and adds it to the
# list of segments filled. Returns the first x coordinate after the part of the
# line that has been filled.
func _flood_line_around_point(position: Vector2, image: Image) -> int:
# this method is called by `_flood_fill` after the required data structures
# have been initialized
if not image.get_pixelv(position).a > 0:
return int(position.x) + 1
var west: Vector2 = position
var east: Vector2 = position
while west.x >= 0 && image.get_pixelv(west).a > 0:
west += Vector2.LEFT
while east.x < image.get_width() && image.get_pixelv(east).a > 0:
east += Vector2.RIGHT
# Make a note of the stuff we processed
var c = int(position.y)
var segment = _allegro_flood_segments[c]
# we may have already processed some segments on this y coordinate
if segment.flooding:
while segment.next > 0:
c = segment.next # index of next segment in this line of image
segment = _allegro_flood_segments[c]
# found last current segment on this line
c = _allegro_flood_segments.size()
segment.next = c
_add_new_segment(int(position.y))
segment = _allegro_flood_segments[c]
# set the values for the current segment
segment.flooding = true
segment.left_position = west.x + 1
segment.right_position = east.x - 1
segment.y = position.y
segment.next = 0
# Should we process segments above or below this one?
# when there is a selected area, the pixels above and below the one we started creating this
# segment from may be outside it. It's easier to assume we should be checking for segments
# above and below this one than to specifically check every single pixel in it, because that
# test will be performed later anyway.
# On the other hand, this test we described is the same `project.can_pixel_get_drawn` does if
# there is no selection, so we don't need branching here.
segment.todo_above = position.y > 0
segment.todo_below = position.y < image.get_height() - 1
# this is an actual segment we should be coloring, so we add it to the results for the
# current image
if segment.right_position >= segment.left_position:
_allegro_image_segments.append(segment)
# we know the point just east of the segment is not part of a segment that should be
# processed, else it would be part of this segment
return int(east.x) + 1


func _check_flooded_segment(y: int, left: int, right: int, image: Image) -> bool:
var ret = false
var c: int = 0
while left <= right:
c = y
while true:
var segment = _allegro_flood_segments[c]
if left >= segment.left_position and left <= segment.right_position:
left = segment.right_position + 2
break
c = segment.next
if c == 0: # couldn't find a valid segment, so we draw a new one
left = _flood_line_around_point(Vector2(left, y), image)
ret = true
break
return ret


func _flood_fill(position: Vector2, image: Image) -> Rect2:
# implements the floodfill routine by Shawn Hargreaves
# from https://www1.udel.edu/CIS/software/dist/allegro-4.2.1/src/flood.c
# init flood data structures
_allegro_flood_segments = []
_allegro_image_segments = []
_compute_segments_for_image(position, image)
# now actually color the image: since we have already checked a few things for the points
# we'll process here, we're going to skip a bunch of safety checks to speed things up.

var final_image = Image.new()
final_image.copy_from(image)
final_image.fill(Color.transparent)
final_image.lock()
_select_segments(final_image)
final_image.unlock()

return final_image.get_used_rect()


func _compute_segments_for_image(position: Vector2, image: Image) -> void:
# initially allocate at least 1 segment per line of image
for j in image.get_height():
_add_new_segment(j)
# start flood algorithm
_flood_line_around_point(position, image)
# test all segments while also discovering more
var done := false
while not done:
done = true
var max_index = _allegro_flood_segments.size()
for c in max_index:
var p = _allegro_flood_segments[c]
if p.todo_below: # check below the segment?
p.todo_below = false
if _check_flooded_segment(p.y + 1, p.left_position, p.right_position, image):
done = false
if p.todo_above: # check above the segment?
p.todo_above = false
if _check_flooded_segment(p.y - 1, p.left_position, p.right_position, image):
done = false


func _select_segments(map: Image) -> void:
# short circuit for flat colors
for c in _allegro_image_segments.size():
var p = _allegro_image_segments[c]
var rect = Rect2()
rect.position = Vector2(p.left_position, p.y)
rect.end = Vector2(p.right_position + 1, p.y + 1)
map.fill_rect(rect, Color.white)
6 changes: 6 additions & 0 deletions project.godot
Expand Up @@ -224,6 +224,11 @@ _global_script_classes=[ {
"language": "GDScript",
"path": "res://src/UI/ReferenceImages/ReferencesPanel.gd"
}, {
"base": "Reference",
"class": "RegionUnpacker",
"language": "GDScript",
"path": "res://addons/SmartSlicer/Classes/RegionUnpacker.gd"
}, {
"base": "Image",
"class": "SelectionMap",
"language": "GDScript",
Expand Down Expand Up @@ -313,6 +318,7 @@ _global_script_class_icons={
"Project": "",
"ReferenceImage": "",
"ReferencesPanel": "",
"RegionUnpacker": "",
"SelectionMap": "",
"SelectionTool": "",
"ShaderImageEffect": "",
Expand Down

0 comments on commit 4242859

Please sign in to comment.