[{"data":1,"prerenderedAt":1392},["ShallowReactive",2],{"tutorial-fast-connected-components-in-javascript":3},{"id":4,"title":5,"author":6,"body":7,"date":1367,"description":1368,"extension":1369,"keywords":1370,"meta":1380,"navigation":151,"ogImage":1381,"path":1382,"published":151,"seo":1383,"stem":1384,"tags":1385,"__hash__":1391},"tutorials\u002Ftutorials\u002Ffast-connected-components-in-javascript.md","Fast Connected Components in JavaScript","Žiga Sajovic",{"type":8,"value":9,"toc":1356},"minimark",[10,28,40,44,51,56,81,94,98,185,196,203,347,351,357,406,412,415,464,470,474,480,615,621,633,636,705,708,712,715,760,766,772,796,806,810,816,909,915,918,954,960,1091,1103,1107,1114,1167,1183,1190,1262,1266,1298,1302,1305,1309,1325,1330,1333,1336,1348,1352],[11,12,13],"note",{},[14,15,16,17,22,23,27],"p",{},"Also available in ",[18,19,21],"a",{"href":20},"\u002Ftutorials\u002Ffast-connected-components-in-python","Python"," and ",[18,24,26],{"href":25},"\u002Ftutorials\u002Ffast-connected-components-in-cpp","C++",".",[14,29,30,31,35,36,39],{},"A scanned or exported mesh often holds more than one piece, and some of those pieces have holes. Two questions come first: which faces belong together, and where are the open edges. ",[32,33,34],"code",{},"trueform"," answers both in JavaScript and TypeScript with one ",[32,37,38],{},"npm install",". It is WebAssembly-powered and runs in Node.js and the browser with the same API. Typed arrays in, typed arrays out. This page covers labeling connected components, splitting a mesh into parts, keeping the largest part, and extracting boundary loops for holes and open edges.",[41,42],"article-cta",{":buttons":43},"[{\"label\":\"trueform\",\"to\":\"\u002Ftrueform\",\"icon\":\"i-lucide-cpu\",\"variant\":\"soft\",\"color\":\"neutral\"},{\"label\":\"GitHub\",\"to\":\"https:\u002F\u002Fgithub.com\u002Fpolydera\u002Ftrueform\",\"icon\":\"i-simple-icons-github\",\"variant\":\"soft\",\"color\":\"neutral\"},{\"label\":\"Documentation\",\"to\":\"https:\u002F\u002Ftrueform.polydera.com\u002Fts\u002Fmodules\u002Ftopology#connected-components\",\"variant\":\"soft\",\"color\":\"neutral\"}]",[14,45,46],{},[47,48],"img",{"alt":49,"src":50},"The Stanford dragon split into separate connected components, with their boundary loops","\u002Fimg\u002Ftutorials\u002Fconnected-components.png",[52,53,55],"h2",{"id":54},"install","Install",[57,58,63],"pre",{"className":59,"code":60,"language":61,"meta":62,"style":62},"language-bash shiki shiki-themes github-dark","npm install @polydera\u002Ftrueform\n","bash","",[32,64,65],{"__ignoreMap":62},[66,67,70,74,78],"span",{"class":68,"line":69},"line",1,[66,71,73],{"class":72},"svObZ","npm",[66,75,77],{"class":76},"sU2Wk"," install",[66,79,80],{"class":76}," @polydera\u002Ftrueform\n",[14,82,83,84,87,88,27],{},"The package ships TypeScript types. The WebAssembly binary loads on first use. In the browser, multithreading needs ",[32,85,86],{},"SharedArrayBuffer"," headers; see the ",[18,89,93],{"href":90,"rel":91},"https:\u002F\u002Ftrueform.polydera.com\u002Fts\u002Fgetting-started\u002Finstallation",[92],"nofollow","installation guide",[52,95,97],{"id":96},"loading-the-mesh","Loading the mesh",[57,99,103],{"className":100,"code":101,"language":102,"meta":62,"style":62},"language-typescript shiki shiki-themes github-dark","import * as tf from \"@polydera\u002Ftrueform\";\nimport { readFileSync } from \"node:fs\";\n\nconst mesh = tf.readStl(readFileSync(\"scan.stl\"));\n","typescript",[32,104,105,131,146,153],{"__ignoreMap":62},[66,106,107,111,115,118,122,125,128],{"class":68,"line":69},[66,108,110],{"class":109},"snl16","import",[66,112,114],{"class":113},"sDLfK"," *",[66,116,117],{"class":109}," as",[66,119,121],{"class":120},"s95oV"," tf ",[66,123,124],{"class":109},"from",[66,126,127],{"class":76}," \"@polydera\u002Ftrueform\"",[66,129,130],{"class":120},";\n",[66,132,134,136,139,141,144],{"class":68,"line":133},2,[66,135,110],{"class":109},[66,137,138],{"class":120}," { readFileSync } ",[66,140,124],{"class":109},[66,142,143],{"class":76}," \"node:fs\"",[66,145,130],{"class":120},[66,147,149],{"class":68,"line":148},3,[66,150,152],{"emptyLinePlaceholder":151},true,"\n",[66,154,156,159,162,165,168,171,174,177,179,182],{"class":68,"line":155},4,[66,157,158],{"class":109},"const",[66,160,161],{"class":113}," mesh",[66,163,164],{"class":109}," =",[66,166,167],{"class":120}," tf.",[66,169,170],{"class":72},"readStl",[66,172,173],{"class":120},"(",[66,175,176],{"class":72},"readFileSync",[66,178,173],{"class":120},[66,180,181],{"class":76},"\"scan.stl\"",[66,183,184],{"class":120},"));\n",[14,186,187,188,191,192,195],{},"In the browser, pass the bytes from ",[32,189,190],{},"fetch"," or a file input. ",[32,193,194],{},"tf.readObj"," works the same way.",[14,197,198,199,202],{},"To follow along without a file, build a mesh with three parts: a closed sphere, a closed box, and an open plane. ",[32,200,201],{},"tf.concatenateMeshes"," applies each part's transformation as it joins them:",[57,204,206],{"className":100,"code":205,"language":102,"meta":62,"style":62},"const box = tf.boxMesh(1, 1, 1);\nbox.transformation = tf.makeTranslation(3, 0, 0);\n\nconst mesh = tf.concatenateMeshes(\n  tf.sphereMesh(1, 32, 32),\n  box,\n  tf.planeMesh(2, 2, 10, 10));\n",[32,207,208,239,268,272,288,313,319],{"__ignoreMap":62},[66,209,210,212,215,217,219,222,224,227,230,232,234,236],{"class":68,"line":69},[66,211,158],{"class":109},[66,213,214],{"class":113}," box",[66,216,164],{"class":109},[66,218,167],{"class":120},[66,220,221],{"class":72},"boxMesh",[66,223,173],{"class":120},[66,225,226],{"class":113},"1",[66,228,229],{"class":120},", ",[66,231,226],{"class":113},[66,233,229],{"class":120},[66,235,226],{"class":113},[66,237,238],{"class":120},");\n",[66,240,241,244,247,249,252,254,257,259,262,264,266],{"class":68,"line":133},[66,242,243],{"class":120},"box.transformation ",[66,245,246],{"class":109},"=",[66,248,167],{"class":120},[66,250,251],{"class":72},"makeTranslation",[66,253,173],{"class":120},[66,255,256],{"class":113},"3",[66,258,229],{"class":120},[66,260,261],{"class":113},"0",[66,263,229],{"class":120},[66,265,261],{"class":113},[66,267,238],{"class":120},[66,269,270],{"class":68,"line":148},[66,271,152],{"emptyLinePlaceholder":151},[66,273,274,276,278,280,282,285],{"class":68,"line":155},[66,275,158],{"class":109},[66,277,161],{"class":113},[66,279,164],{"class":109},[66,281,167],{"class":120},[66,283,284],{"class":72},"concatenateMeshes",[66,286,287],{"class":120},"(\n",[66,289,291,294,297,299,301,303,306,308,310],{"class":68,"line":290},5,[66,292,293],{"class":120},"  tf.",[66,295,296],{"class":72},"sphereMesh",[66,298,173],{"class":120},[66,300,226],{"class":113},[66,302,229],{"class":120},[66,304,305],{"class":113},"32",[66,307,229],{"class":120},[66,309,305],{"class":113},[66,311,312],{"class":120},"),\n",[66,314,316],{"class":68,"line":315},6,[66,317,318],{"class":120},"  box,\n",[66,320,322,324,327,329,332,334,336,338,341,343,345],{"class":68,"line":321},7,[66,323,293],{"class":120},[66,325,326],{"class":72},"planeMesh",[66,328,173],{"class":120},[66,330,331],{"class":113},"2",[66,333,229],{"class":120},[66,335,331],{"class":113},[66,337,229],{"class":120},[66,339,340],{"class":113},"10",[66,342,229],{"class":120},[66,344,340],{"class":113},[66,346,184],{"class":120},[52,348,350],{"id":349},"labeling-connected-components","Labeling connected components",[14,352,353,356],{},[32,354,355],{},"tf.connectedComponents"," gives every face a component id:",[57,358,360],{"className":100,"code":359,"language":102,"meta":62,"style":62},"const { labels, nComponents } = tf.connectedComponents(mesh, \"edge\");\n\u002F\u002F nComponents: 3\n\u002F\u002F labels:      NDArrayInt32 [F], one id per face\n",[32,361,362,395,401],{"__ignoreMap":62},[66,363,364,366,369,372,374,377,380,382,384,387,390,393],{"class":68,"line":69},[66,365,158],{"class":109},[66,367,368],{"class":120}," { ",[66,370,371],{"class":113},"labels",[66,373,229],{"class":120},[66,375,376],{"class":113},"nComponents",[66,378,379],{"class":120}," } ",[66,381,246],{"class":109},[66,383,167],{"class":120},[66,385,386],{"class":72},"connectedComponents",[66,388,389],{"class":120},"(mesh, ",[66,391,392],{"class":76},"\"edge\"",[66,394,238],{"class":120},[66,396,397],{"class":68,"line":133},[66,398,400],{"class":399},"sAwPA","\u002F\u002F nComponents: 3\n",[66,402,403],{"class":68,"line":148},[66,404,405],{"class":399},"\u002F\u002F labels:      NDArrayInt32 [F], one id per face\n",[14,407,408],{},[47,409],{"alt":410,"src":411},"Step: labeling connected components. The Utah teapot colored by component label: body, lid, spout and handle are four separate shells","\u002Fimg\u002Ftutorials\u002Fsteps\u002Ffast-connected-components-1-labels.png",[14,413,414],{},"The second argument picks the connectivity:",[416,417,418,431],"table",{},[419,420,421],"thead",{},[422,423,424,428],"tr",{},[425,426,427],"th",{},"Type",[425,429,430],{},"Faces are connected when",[432,433,434,444,454],"tbody",{},[422,435,436,441],{},[437,438,439],"td",{},[32,440,392],{},[437,442,443],{},"they share any edge",[422,445,446,451],{},[437,447,448],{},[32,449,450],{},"\"manifoldEdge\"",[437,452,453],{},"they share an edge that has exactly two faces",[422,455,456,461],{},[437,457,458],{},[32,459,460],{},"\"vertex\"",[437,462,463],{},"labels vertices instead, connected by shared edges",[14,465,466,467,469],{},"Use ",[32,468,450],{}," to cut a mesh apart at its non-manifold edges.",[52,471,473],{"id":472},"splitting-into-parts","Splitting into parts",[14,475,476,479],{},[32,477,478],{},"tf.splitIntoComponents"," turns the labels into separate meshes:",[57,481,483],{"className":100,"code":482,"language":102,"meta":62,"style":62},"const { components, labels: ids } = tf.splitIntoComponents(mesh, labels);\n\nfor (let i = 0; i \u003C components.length; i++) {\n  console.log(`component ${ids.data[i]}: ${components[i].numberOfFaces} faces`);\n}\n",[32,484,485,517,521,561,610],{"__ignoreMap":62},[66,486,487,489,491,494,496,499,502,505,507,509,511,514],{"class":68,"line":69},[66,488,158],{"class":109},[66,490,368],{"class":120},[66,492,493],{"class":113},"components",[66,495,229],{"class":120},[66,497,371],{"class":498},"s9osk",[66,500,501],{"class":120},": ",[66,503,504],{"class":113},"ids",[66,506,379],{"class":120},[66,508,246],{"class":109},[66,510,167],{"class":120},[66,512,513],{"class":72},"splitIntoComponents",[66,515,516],{"class":120},"(mesh, labels);\n",[66,518,519],{"class":68,"line":133},[66,520,152],{"emptyLinePlaceholder":151},[66,522,523,526,529,532,535,537,540,543,546,549,552,555,558],{"class":68,"line":148},[66,524,525],{"class":109},"for",[66,527,528],{"class":120}," (",[66,530,531],{"class":109},"let",[66,533,534],{"class":120}," i ",[66,536,246],{"class":109},[66,538,539],{"class":113}," 0",[66,541,542],{"class":120},"; i ",[66,544,545],{"class":109},"\u003C",[66,547,548],{"class":120}," components.",[66,550,551],{"class":113},"length",[66,553,554],{"class":120},"; i",[66,556,557],{"class":109},"++",[66,559,560],{"class":120},") {\n",[66,562,563,566,569,571,574,576,578,581,584,587,590,593,595,597,599,602,605,608],{"class":68,"line":155},[66,564,565],{"class":120},"  console.",[66,567,568],{"class":72},"log",[66,570,173],{"class":120},[66,572,573],{"class":76},"`component ${",[66,575,504],{"class":120},[66,577,27],{"class":76},[66,579,580],{"class":120},"data",[66,582,583],{"class":76},"[",[66,585,586],{"class":120},"i",[66,588,589],{"class":76},"]",[66,591,592],{"class":76},"}: ${",[66,594,493],{"class":120},[66,596,583],{"class":76},[66,598,586],{"class":120},[66,600,601],{"class":76},"].",[66,603,604],{"class":120},"numberOfFaces",[66,606,607],{"class":76},"} faces`",[66,609,238],{"class":120},[66,611,612],{"class":68,"line":290},[66,613,614],{"class":120},"}\n",[14,616,617],{},[47,618],{"alt":619,"src":620},"Step: splitting into parts. The teapot split into four standalone meshes, one per label, pulled apart","\u002Fimg\u002Ftutorials\u002Fsteps\u002Ffast-connected-components-2-split.png",[14,622,623,625,626,629,630,27],{},[32,624,504],{}," is an ",[32,627,628],{},"NDArrayInt32",", so read it through ",[32,631,632],{},".data",[14,634,635],{},"To keep only the largest part, count faces per label and filter by mask:",[57,637,639],{"className":100,"code":638,"language":102,"meta":62,"style":62},"const counts = tf.bincount(labels);            \u002F\u002F faces per component\nconst largest = counts.argmax();               \u002F\u002F its label\nconst main = tf.reindexedByMask(mesh, labels.eq(largest));\n",[32,640,641,661,682],{"__ignoreMap":62},[66,642,643,645,648,650,652,655,658],{"class":68,"line":69},[66,644,158],{"class":109},[66,646,647],{"class":113}," counts",[66,649,164],{"class":109},[66,651,167],{"class":120},[66,653,654],{"class":72},"bincount",[66,656,657],{"class":120},"(labels);            ",[66,659,660],{"class":399},"\u002F\u002F faces per component\n",[66,662,663,665,668,670,673,676,679],{"class":68,"line":133},[66,664,158],{"class":109},[66,666,667],{"class":113}," largest",[66,669,164],{"class":109},[66,671,672],{"class":120}," counts.",[66,674,675],{"class":72},"argmax",[66,677,678],{"class":120},"();               ",[66,680,681],{"class":399},"\u002F\u002F its label\n",[66,683,684,686,689,691,693,696,699,702],{"class":68,"line":148},[66,685,158],{"class":109},[66,687,688],{"class":113}," main",[66,690,164],{"class":109},[66,692,167],{"class":120},[66,694,695],{"class":72},"reindexedByMask",[66,697,698],{"class":120},"(mesh, labels.",[66,700,701],{"class":72},"eq",[66,703,704],{"class":120},"(largest));\n",[14,706,707],{},"This is the usual way to drop floating debris from a scan.",[52,709,711],{"id":710},"finding-holes-and-open-edges","Finding holes and open edges",[14,713,714],{},"A closed mesh has no boundary edges. Check it first:",[57,716,718],{"className":100,"code":717,"language":102,"meta":62,"style":62},"tf.isClosed(mesh);              \u002F\u002F false, the plane is open\ntf.isManifold(mesh);            \u002F\u002F true\ntf.eulerCharacteristic(mesh);   \u002F\u002F V - E + F\n",[32,719,720,734,747],{"__ignoreMap":62},[66,721,722,725,728,731],{"class":68,"line":69},[66,723,724],{"class":120},"tf.",[66,726,727],{"class":72},"isClosed",[66,729,730],{"class":120},"(mesh);              ",[66,732,733],{"class":399},"\u002F\u002F false, the plane is open\n",[66,735,736,738,741,744],{"class":68,"line":133},[66,737,724],{"class":120},[66,739,740],{"class":72},"isManifold",[66,742,743],{"class":120},"(mesh);            ",[66,745,746],{"class":399},"\u002F\u002F true\n",[66,748,749,751,754,757],{"class":68,"line":148},[66,750,724],{"class":120},[66,752,753],{"class":72},"eulerCharacteristic",[66,755,756],{"class":120},"(mesh);   ",[66,758,759],{"class":399},"\u002F\u002F V - E + F\n",[14,761,762],{},[47,763],{"alt":764,"src":765},"Step: finding holes and open edges. The underside of the Stanford bunny, an open scan, with its boundary edges in pink","\u002Fimg\u002Ftutorials\u002Fsteps\u002Ffast-connected-components-3-holes.png",[14,767,768,771],{},[32,769,770],{},"tf.boundaryEdges"," lists every edge that has only one face, as vertex pairs:",[57,773,775],{"className":100,"code":774,"language":102,"meta":62,"style":62},"const edges = tf.boundaryEdges(mesh);   \u002F\u002F NDArrayInt32 [N, 2]\n",[32,776,777],{"__ignoreMap":62},[66,778,779,781,784,786,788,791,793],{"class":68,"line":69},[66,780,158],{"class":109},[66,782,783],{"class":113}," edges",[66,785,164],{"class":109},[66,787,167],{"class":120},[66,789,790],{"class":72},"boundaryEdges",[66,792,756],{"class":120},[66,794,795],{"class":399},"\u002F\u002F NDArrayInt32 [N, 2]\n",[14,797,798,799,22,802,805],{},"Edges with three or more faces are a different problem. ",[32,800,801],{},"tf.nonManifoldEdges(mesh)",[32,803,804],{},"tf.nonManifoldVertices(mesh)"," find those.",[52,807,809],{"id":808},"boundary-loops","Boundary loops",[14,811,812,815],{},[32,813,814],{},"tf.boundaryPaths"," joins the boundary edges into loops of vertex ids:",[57,817,819],{"className":100,"code":818,"language":102,"meta":62,"style":62},"const paths = tf.boundaryPaths(mesh);   \u002F\u002F OffsetBlockedBuffer\n\nfor (const path of paths) {\n  const ids = path.data;                \u002F\u002F Int32Array of vertex ids\n  console.log(`loop of ${ids.length - 1} edges`);\n}\n",[32,820,821,840,844,861,877,905],{"__ignoreMap":62},[66,822,823,825,828,830,832,835,837],{"class":68,"line":69},[66,824,158],{"class":109},[66,826,827],{"class":113}," paths",[66,829,164],{"class":109},[66,831,167],{"class":120},[66,833,834],{"class":72},"boundaryPaths",[66,836,756],{"class":120},[66,838,839],{"class":399},"\u002F\u002F OffsetBlockedBuffer\n",[66,841,842],{"class":68,"line":133},[66,843,152],{"emptyLinePlaceholder":151},[66,845,846,848,850,852,855,858],{"class":68,"line":148},[66,847,525],{"class":109},[66,849,528],{"class":120},[66,851,158],{"class":109},[66,853,854],{"class":113}," path",[66,856,857],{"class":109}," of",[66,859,860],{"class":120}," paths) {\n",[66,862,863,866,869,871,874],{"class":68,"line":155},[66,864,865],{"class":109},"  const",[66,867,868],{"class":113}," ids",[66,870,164],{"class":109},[66,872,873],{"class":120}," path.data;                ",[66,875,876],{"class":399},"\u002F\u002F Int32Array of vertex ids\n",[66,878,879,881,883,885,888,890,892,894,897,900,903],{"class":68,"line":290},[66,880,565],{"class":120},[66,882,568],{"class":72},[66,884,173],{"class":120},[66,886,887],{"class":76},"`loop of ${",[66,889,504],{"class":120},[66,891,27],{"class":76},[66,893,551],{"class":113},[66,895,896],{"class":109}," -",[66,898,899],{"class":113}," 1",[66,901,902],{"class":76},"} edges`",[66,904,238],{"class":120},[66,906,907],{"class":68,"line":315},[66,908,614],{"class":120},[14,910,911],{},[47,912],{"alt":913,"src":914},"Step: boundary loops. The bunny's open edges chained into five loops, each in its own color","\u002Fimg\u002Ftutorials\u002Fsteps\u002Ffast-connected-components-4-loops.png",[14,916,917],{},"A closed loop repeats its first vertex at the end, so a loop of 40 edges holds 41 ids. The loop's points are one gather:",[57,919,921],{"className":100,"code":920,"language":102,"meta":62,"style":62},"const loopPoints = mesh.points.take(paths.get(0));   \u002F\u002F [41, 3]\n",[32,922,923],{"__ignoreMap":62},[66,924,925,927,930,932,935,938,941,944,946,948,951],{"class":68,"line":69},[66,926,158],{"class":109},[66,928,929],{"class":113}," loopPoints",[66,931,164],{"class":109},[66,933,934],{"class":120}," mesh.points.",[66,936,937],{"class":72},"take",[66,939,940],{"class":120},"(paths.",[66,942,943],{"class":72},"get",[66,945,173],{"class":120},[66,947,261],{"class":113},[66,949,950],{"class":120},"));   ",[66,952,953],{"class":399},"\u002F\u002F [41, 3]\n",[14,955,956,959],{},[32,957,958],{},"tf.boundaryRims"," reads the same boundary with more detail. For each rim it gives the vertex walk, the face that carries each rim edge, and whether the rim closes. Each rim lists every vertex once:",[57,961,963],{"className":100,"code":962,"language":102,"meta":62,"style":62},"const { vertices, faces, closed } = tf.boundaryRims(mesh);\n\nfor (let i = 0; i \u003C vertices.length; i++) {\n  const walk = vertices.get(i);       \u002F\u002F vertex ids in walk order\n  const carriers = faces.get(i);      \u002F\u002F one face per rim edge\n  const isClosed = closed.data[i] !== 0;\n}\n",[32,964,965,996,1000,1029,1048,1068,1087],{"__ignoreMap":62},[66,966,967,969,971,974,976,979,981,984,986,988,990,993],{"class":68,"line":69},[66,968,158],{"class":109},[66,970,368],{"class":120},[66,972,973],{"class":113},"vertices",[66,975,229],{"class":120},[66,977,978],{"class":113},"faces",[66,980,229],{"class":120},[66,982,983],{"class":113},"closed",[66,985,379],{"class":120},[66,987,246],{"class":109},[66,989,167],{"class":120},[66,991,992],{"class":72},"boundaryRims",[66,994,995],{"class":120},"(mesh);\n",[66,997,998],{"class":68,"line":133},[66,999,152],{"emptyLinePlaceholder":151},[66,1001,1002,1004,1006,1008,1010,1012,1014,1016,1018,1021,1023,1025,1027],{"class":68,"line":148},[66,1003,525],{"class":109},[66,1005,528],{"class":120},[66,1007,531],{"class":109},[66,1009,534],{"class":120},[66,1011,246],{"class":109},[66,1013,539],{"class":113},[66,1015,542],{"class":120},[66,1017,545],{"class":109},[66,1019,1020],{"class":120}," vertices.",[66,1022,551],{"class":113},[66,1024,554],{"class":120},[66,1026,557],{"class":109},[66,1028,560],{"class":120},[66,1030,1031,1033,1036,1038,1040,1042,1045],{"class":68,"line":155},[66,1032,865],{"class":109},[66,1034,1035],{"class":113}," walk",[66,1037,164],{"class":109},[66,1039,1020],{"class":120},[66,1041,943],{"class":72},[66,1043,1044],{"class":120},"(i);       ",[66,1046,1047],{"class":399},"\u002F\u002F vertex ids in walk order\n",[66,1049,1050,1052,1055,1057,1060,1062,1065],{"class":68,"line":290},[66,1051,865],{"class":109},[66,1053,1054],{"class":113}," carriers",[66,1056,164],{"class":109},[66,1058,1059],{"class":120}," faces.",[66,1061,943],{"class":72},[66,1063,1064],{"class":120},"(i);      ",[66,1066,1067],{"class":399},"\u002F\u002F one face per rim edge\n",[66,1069,1070,1072,1075,1077,1080,1083,1085],{"class":68,"line":315},[66,1071,865],{"class":109},[66,1073,1074],{"class":113}," isClosed",[66,1076,164],{"class":109},[66,1078,1079],{"class":120}," closed.data[i] ",[66,1081,1082],{"class":109},"!==",[66,1084,539],{"class":113},[66,1086,130],{"class":120},[66,1088,1089],{"class":68,"line":321},[66,1090,614],{"class":120},[14,1092,1093,1094,1097,1098,27],{},"Rims are what the hole filler ",[32,1095,1096],{},"tf.fillHoles"," takes; see the ",[18,1099,1102],{"href":1100,"rel":1101},"https:\u002F\u002Ftrueform.polydera.com\u002Fts\u002Fmodules\u002Ffill",[92],"Fill module docs",[52,1104,1106],{"id":1105},"working-with-results","Working with results",[14,1108,1109,1110,1113],{},"The face labels are per-face data. Map them to colors for display, or pass them to the remeshers as ",[32,1111,1112],{},"preserveRegions"," to keep the parts apart. To draw the boundary, turn the loops into tubes:",[57,1115,1117],{"className":100,"code":1116,"language":102,"meta":62,"style":62},"const curves = tf.curves(paths, mesh.points);\nconst tubes = tf.tubeMesh(curves, 0.01, 8);   \u002F\u002F radius, radial segments\n",[32,1118,1119,1136],{"__ignoreMap":62},[66,1120,1121,1123,1126,1128,1130,1133],{"class":68,"line":69},[66,1122,158],{"class":109},[66,1124,1125],{"class":113}," curves",[66,1127,164],{"class":109},[66,1129,167],{"class":120},[66,1131,1132],{"class":72},"curves",[66,1134,1135],{"class":120},"(paths, mesh.points);\n",[66,1137,1138,1140,1143,1145,1147,1150,1153,1156,1158,1161,1164],{"class":68,"line":133},[66,1139,158],{"class":109},[66,1141,1142],{"class":113}," tubes",[66,1144,164],{"class":109},[66,1146,167],{"class":120},[66,1148,1149],{"class":72},"tubeMesh",[66,1151,1152],{"class":120},"(curves, ",[66,1154,1155],{"class":113},"0.01",[66,1157,229],{"class":120},[66,1159,1160],{"class":113},"8",[66,1162,1163],{"class":120},");   ",[66,1165,1166],{"class":399},"\u002F\u002F radius, radial segments\n",[14,1168,1169,1172,1173,22,1176,1179,1180,27],{},[32,1170,1171],{},"tubes"," is a regular mesh. Its ",[32,1174,1175],{},"faces.data",[32,1177,1178],{},"points.data"," go straight into a Three.js ",[32,1181,1182],{},"BufferGeometry",[14,1184,1185,1186,1189],{},"Every call here has an async twin under ",[32,1187,1188],{},"tf.async"," that runs off the main thread:",[57,1191,1193],{"className":100,"code":1192,"language":102,"meta":62,"style":62},"const { labels, nComponents } = await tf.async.connectedComponents(mesh, \"edge\");\nconst { components } = await tf.async.splitIntoComponents(mesh, labels);\nconst rims = await tf.async.boundaryRims(mesh);\n",[32,1194,1195,1225,1245],{"__ignoreMap":62},[66,1196,1197,1199,1201,1203,1205,1207,1209,1211,1214,1217,1219,1221,1223],{"class":68,"line":69},[66,1198,158],{"class":109},[66,1200,368],{"class":120},[66,1202,371],{"class":113},[66,1204,229],{"class":120},[66,1206,376],{"class":113},[66,1208,379],{"class":120},[66,1210,246],{"class":109},[66,1212,1213],{"class":109}," await",[66,1215,1216],{"class":120}," tf.async.",[66,1218,386],{"class":72},[66,1220,389],{"class":120},[66,1222,392],{"class":76},[66,1224,238],{"class":120},[66,1226,1227,1229,1231,1233,1235,1237,1239,1241,1243],{"class":68,"line":133},[66,1228,158],{"class":109},[66,1230,368],{"class":120},[66,1232,493],{"class":113},[66,1234,379],{"class":120},[66,1236,246],{"class":109},[66,1238,1213],{"class":109},[66,1240,1216],{"class":120},[66,1242,513],{"class":72},[66,1244,516],{"class":120},[66,1246,1247,1249,1252,1254,1256,1258,1260],{"class":68,"line":148},[66,1248,158],{"class":109},[66,1250,1251],{"class":113}," rims",[66,1253,164],{"class":109},[66,1255,1213],{"class":109},[66,1257,1216],{"class":120},[66,1259,992],{"class":72},[66,1261,995],{"class":120},[52,1263,1265],{"id":1264},"related-tutorials","Related tutorials",[1267,1268,1269,1277,1284,1291],"ul",{},[1270,1271,1272,1276],"li",{},[18,1273,1275],{"href":1274},"\u002Ftutorials\u002Ffast-mesh-booleans-in-javascript","Fast Mesh Booleans in JavaScript"," — exact union, intersection and difference of two meshes.",[1270,1278,1279,1283],{},[18,1280,1282],{"href":1281},"\u002Ftutorials\u002Ffast-stl-and-obj-reading-in-javascript","Fast STL and OBJ Reading in JavaScript"," — load and write meshes, the first step of every pipeline.",[1270,1285,1286,1290],{},[18,1287,1289],{"href":1288},"\u002Ftutorials\u002Ffast-closest-point-on-a-mesh-in-javascript","Fast and Robust Closest Point on a Mesh in JavaScript"," — nearest surface point for a batch of queries.",[1270,1292,1293,1297],{},[18,1294,1296],{"href":1295},"\u002Ftutorials\u002Fmesh-slicing-and-cross-sections-in-javascript","Fast and Robust Mesh Slicing and Cross-Sections in JavaScript"," — plane sections, layers and slabs of a mesh.",[52,1299,1301],{"id":1300},"performance","Performance",[14,1303,1304],{},"We measured connected components on 100 Thingi10K meshes (100,368 to 938,940 faces) and the dragon at five sizes. Each run labels every face by edge connectivity.",[1306,1307],"headline-numbers",{":items":1308},"[{\"value\":\"20ms\",\"label\":\"Components, 1,025,766 faces\",\"detail\":\"Labels for every face, adjacency built included\"},{\"value\":\"1.9ms\",\"label\":\"Components, 58,298 faces\",\"detail\":\"Labels for every face, adjacency built included\"},{\"value\":\"3.2ms\",\"label\":\"Components, typical of 100 meshes\",\"detail\":\"Geometric mean\"}]",[14,1310,1311,1316,1317,1316,1321],{},[66,1312,1315],{"className":1313},[1314],"tag-pill","Apple M4 Max"," ",[66,1318,1320],{"className":1319},[1314],"16 threads",[66,1322,1324],{"className":1323},[1314],"Node.js 25 · WebAssembly",[1326,1327],"data-table",{":headers":1328,":highlight":261,":rows":1329},"[\"Faces\",\"Meshes\",\"Components\"]","[[\"100,368–145,418\",\"40\",\"1.86 ms\"],[\"153,248–242,390\",\"26\",\"3.09 ms\"],[\"255,508–498,974\",\"16\",\"5.02 ms\"],[\"587,688–938,940\",\"18\",\"7.45 ms\"],[\"Dragon, 58,298\",\"1\",\"1.87 ms\"],[\"Dragon, 141,966\",\"1\",\"3.35 ms\"],[\"Dragon, 244,026\",\"1\",\"5.92 ms\"],[\"Dragon, 480,868\",\"1\",\"11.23 ms\"],[\"Dragon, 1,025,766\",\"1\",\"20.13 ms\"]]",[14,1331,1332],{},"The Thingi10K rows are geometric means over the meshes in each size range. The times start from the vertex and index arrays. They include building the mesh and its face adjacency, and copying the labels out as typed arrays. trueform ran on its WebAssembly thread pool of 16 workers. No peer library in JavaScript was timed.",[14,1334,1335],{},"Best of the timed runs after warm-ups, measured 2026-11-01 with trueform 0.10.8; harness and raw results: benchmarks\u002Ftutorials\u002Fjavascript-connected-components.",[14,1337,1338,1342,1343],{},[18,1339,1341],{"href":1340},"\u002Ftrueform\u002Fbenchmarks","Full benchmarks"," · ",[18,1344,1347],{"href":1345,"rel":1346},"https:\u002F\u002Ftrueform.polydera.com\u002Fts\u002Fmodules\u002Ftopology#connected-components",[92],"Documentation",[1349,1350],"cite-as",{"author":1351,"title":5},"Sajovic, {\\v{Z}}iga",[1353,1354,1355],"style",{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":62,"searchDepth":133,"depth":133,"links":1357},[1358,1359,1360,1361,1362,1363,1364,1365,1366],{"id":54,"depth":133,"text":55},{"id":96,"depth":133,"text":97},{"id":349,"depth":133,"text":350},{"id":472,"depth":133,"text":473},{"id":710,"depth":133,"text":711},{"id":808,"depth":133,"text":809},{"id":1105,"depth":133,"text":1106},{"id":1264,"depth":133,"text":1265},{"id":1300,"depth":133,"text":1301},"2026-10-31","Label the connected components of a triangle mesh, split it into separate parts, and find its holes and open edges as boundary loops, in JavaScript and TypeScript. WebAssembly-powered, runs in Node.js and the browser.","md",[1371,1372,1373,1374,1375,1376,1377,1378,1379],"mesh connected components JavaScript","split mesh into parts JavaScript","separate mesh shells TypeScript","mesh boundary loops JavaScript","find holes in mesh browser","open edges mesh JavaScript","mesh boundary edges WebAssembly","three.js split mesh into parts","npm mesh connected components",{},"\u002Fimg\u002Fog\u002Ftutorials\u002Ffast-connected-components-in-javascript.png","\u002Ftutorials\u002Ffast-connected-components-in-javascript",{"title":5,"description":1368},"tutorials\u002Ffast-connected-components-in-javascript",[1386,102,1387,1388,1389,1390],"javascript","topology","connected-components","tutorial","webassembly","0CD_484GGWgfWRgJJhcqkpZmpX-mAaw3gTyztIsiFTg",1793517812008]