This approach (source below) still contains some redundancy (could be made more efficient by fusing **sortOn** with **groupBy**, to reduce the number of times that any given shape is queried for its *ptwShape* value).

Nevertheless, it is already a bit more linear, and takes 20 seconds here with 4000 shapes (creating 2000 pair groups);

It calls an omniJS function from JXA, so you can run it from Script Editor etc.

```
(() => {
'use strict';
// OG JS Context (omniJS) -----------------------------------------------
const ogJSContext = () => {
// Tuple (,) :: a -> b -> (a, b)
const Tuple = (a, b) => ({
type: 'Tuple',
'0': a,
'1': b
});
// compare :: a -> a -> Ordering
const compare = (a, b) => a < b ? -1 : (a > b ? 1 : 0);
// flatten :: NestedList a -> [a]
const flatten = t =>
Array.isArray(t) ? (
[].concat.apply([], t.map(flatten))
) : t;
// Typical usage: groupBy(on(eq, f), xs)
// groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
const groupBy = (f, xs) => {
const dct = xs.slice(1)
.reduce((a, x) => {
const h = a.active.length > 0 ? a.active[0] : undefined;
return h !== undefined && f(h, x) ? {
active: a.active.concat([x]),
sofar: a.sofar
} : {
active: [x],
sofar: a.sofar.concat([a.active])
};
}, {
active: xs.length > 0 ? [xs[0]] : [],
sofar: []
});
return dct.sofar.concat(dct.active.length > 0 ? [dct.active] : []);
};
// map :: (a -> b) -> [a] -> [b]
const map = (f, xs) => xs.map(f);
// mappendComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
const mappendComparing = fboolPairs =>
(x, y) => fboolPairs.reduce(
(ord, fb) => {
const f = fb[0];
return ord !== 0 ? (
ord
) : fb[1] ? (
compare(f(x), f(y))
) : compare(f(y), f(x));
}, 0
);
// sortBy :: (a -> a -> Ordering) -> [a] -> [a]
const sortBy = (f, xs) =>
xs.slice()
.sort(f);
// Sort a list by comparing the results of a key function applied to each
// element. sortOn f is equivalent to sortBy (comparing f), but has the
// performance advantage of only evaluating f once for each element in
// the input list. This is called the decorate-sort-undecorate paradigm,
// or Schwartzian transform.
// Elements are arranged from from lowest to highest.
// sortOn :: Ord b => (a -> b) -> [a] -> [a]
// sortOn :: Ord b => [((a -> b), Bool)] -> [a] -> [a]
const sortOn = (f, xs) => {
// Functions and matching bools derived from argument f
// which may be a single key function, or a list of key functions
// each of which may or may not be followed by a direction bool.
const fsbs = unzip(
flatten([f])
.reduceRight((a, x) =>
typeof x === 'boolean' ? {
asc: x,
fbs: a.fbs
} : {
asc: true,
fbs: [
[x, a.asc]
].concat(a.fbs)
}, {
asc: true,
fbs: []
})
.fbs
),
[fs, bs] = [fsbs[0], fsbs[1]],
iLast = fs.length;
// decorate-sort-undecorate
return sortBy(mappendComparing(
// functions that access pre-calculated values by position
// in the decorated ('Schwartzian') version of xs
zip(fs.map((_, i) => x => x[i]), bs)
), xs.map( // xs decorated with precalculated key function values
x => fs.reduceRight(
(a, g) => [g(x)].concat(a), [
x
])))
.map(x => x[iLast]); // undecorated version of data, post sort.
};
// showJSON :: a -> String
const showJSON = x => JSON.stringify(x, null, 2);
// unzip :: [(a,b)] -> ([a],[b])
const unzip = xys =>
xys.reduce(
(a, x) => Tuple.apply(null, [0, 1].map(
i => a[i].concat(x[i])
)),
Tuple([], [])
);
// zip :: [a] -> [b] -> [(a, b)]
const zip = (xs, ys) =>
xs.slice(0, Math.min(xs.length, ys.length))
.map((x, i) => Tuple(x, ys[i]));
// MAIN --------------------------------------------------------------
const
sorted = sortOn(
g => g.userData['ptwShape'],
canvases[0].graphics
),
inGroups = groupBy(
(a, b) => a.userData['ptwShape'] === b.userData['ptwShape'],
sorted
);
return showJSON(
map(xs => {
const
grp = new Group(xs),
k = xs[0].name;
return (
grp.name = k,
grp.setUserData('ptwGroup', 'system'),
grp.locked = true,
k
);
},
inGroups
)
);
};
// Javascript for Automation JS Context (JXA) ----------------------------
return Application('OmniGraffle')
.evaluateJavascript(
'(' + ogJSContext + ')()'
);
})();
```