feat: advance FreeCAD parity and native OCCT history
This commit is contained in:
28
native/occt-history/CMakeLists.txt
Normal file
28
native/occt-history/CMakeLists.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(bitbybit_occt_history LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
if(NOT OCCT_DIR)
|
||||
message(FATAL_ERROR "OCCT_DIR must point to an Emscripten OCCT build containing OpenCASCADEConfig.cmake")
|
||||
endif()
|
||||
|
||||
find_package(OpenCASCADE REQUIRED CONFIG PATHS "${OCCT_DIR}" NO_DEFAULT_PATH)
|
||||
|
||||
add_executable(bitbybit_occt_history occt_history.cpp)
|
||||
target_include_directories(bitbybit_occt_history PRIVATE "${OCCT_DIR}/include/opencascade")
|
||||
target_link_libraries(bitbybit_occt_history PRIVATE TKDESTEP TKXSBase TKDE TKBO TKBRep TKTopAlgo TKPrim TKShHealing TKGeomAlgo TKGeomBase TKG3d TKG2d TKMath TKernel)
|
||||
target_compile_options(bitbybit_occt_history PRIVATE -fexceptions -O2)
|
||||
target_link_options(bitbybit_occt_history PRIVATE
|
||||
--bind
|
||||
-fexceptions
|
||||
-sMODULARIZE=1
|
||||
-sEXPORT_ES6=1
|
||||
-sENVIRONMENT=web,worker,node
|
||||
-sALLOW_MEMORY_GROWTH=1
|
||||
-sINITIAL_MEMORY=134217728
|
||||
-sMAXIMUM_MEMORY=2147483648
|
||||
-sNO_EXIT_RUNTIME=1
|
||||
)
|
||||
set_target_properties(bitbybit_occt_history PROPERTIES OUTPUT_NAME "bitbybit-occt-history")
|
||||
34
native/occt-history/README.md
Normal file
34
native/occt-history/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Native OCCT history provider
|
||||
|
||||
This provider is compiled from the cached OCCT checkout selected by
|
||||
`OCCT_SOURCE_DIR` and exposes the native Boolean history API through Emscripten
|
||||
Embind. The provider keeps `TopoDS_Shape` handles and history queries in one
|
||||
WASM instance. A handle from `@bitbybit-dev/occt` must never be passed directly
|
||||
to this module because WebAssembly linear memories are independent.
|
||||
|
||||
Build and verify it with the project wrapper:
|
||||
|
||||
```bash
|
||||
./npmw run build:occt-history
|
||||
./npmw run test:occt-history
|
||||
```
|
||||
|
||||
The build also publishes the JS/WASM pair to
|
||||
`public/native/occt-history/`, which is the default URL used by
|
||||
`NativeOcctHistoryWorkerProvider`. The generated artifacts remain ignored; a
|
||||
clean checkout must run the build after restoring the pinned OCCT source and
|
||||
Emscripten toolchain.
|
||||
|
||||
The exported `booleanHistory(object, tool, operation)` function supports
|
||||
`fuse`, `cut`, and `common`. It returns the native result shape, per-input
|
||||
`modified`/`generated`/`deleted` records, and capability flags. The provider also
|
||||
exports `shapeToStep(shape)` and `booleanHistoryFromStep(objectStep, toolStep,
|
||||
operation)`. The latter is the cross-WASM transport: Bitbybit exports STEP text,
|
||||
the native provider reads it with `STEPControl_Reader`, returns the Boolean
|
||||
result again as `resultStep`, and no linear-memory pointer crosses the boundary.
|
||||
|
||||
The TypeScript boundary mapper is `mapNativeOcctHistoryRecords`; the reusable
|
||||
`createNativeOcctStepHistoryBridge` exports Bitbybit ShapeHandles to STEP and
|
||||
converts provider source names into document object IDs before calling
|
||||
`captureNativeTopologyHistory`. The direct handle API remains for calls that
|
||||
stay inside this single native WASM instance.
|
||||
79
native/occt-history/build.sh
Executable file
79
native/occt-history/build.sh
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
OCCT_SOURCE_DIR="${OCCT_SOURCE_DIR:-/home/mes123456/working/WebCam/occt}"
|
||||
OCCT_BUILD_DIR="${OCCT_BUILD_DIR:-${ROOT_DIR}/.cache/bitbybit/occt-history-build}"
|
||||
DIST_DIR="${OCCT_HISTORY_DIST_DIR:-${ROOT_DIR}/native/occt-history/dist}"
|
||||
PUBLIC_DIR="${OCCT_HISTORY_PUBLIC_DIR:-${ROOT_DIR}/public/native/occt-history}"
|
||||
JOBS="${JOBS:-$(nproc)}"
|
||||
|
||||
if [[ ! -f "${OCCT_SOURCE_DIR}/CMakeLists.txt" ]]; then
|
||||
echo "OCCT source checkout not found: ${OCCT_SOURCE_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v emcmake >/dev/null || ! command -v em++ >/dev/null; then
|
||||
echo "Emscripten emcmake/em++ are required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
emcmake cmake -S "${OCCT_SOURCE_DIR}" -B "${OCCT_BUILD_DIR}" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_MAKE_PROGRAM="$(command -v ninja)" \
|
||||
-DCMAKE_CXX_FLAGS="-fexceptions" \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O0 -DNDEBUG -fexceptions" \
|
||||
-DBUILD_RELEASE_DISABLE_EXCEPTIONS=OFF \
|
||||
-DBUILD_LIBRARY_TYPE=Static \
|
||||
-DBUILD_MODULE_FoundationClasses=ON \
|
||||
-DBUILD_MODULE_ModelingData=ON \
|
||||
-DBUILD_MODULE_ModelingAlgorithms=OFF \
|
||||
-DBUILD_MODULE_Visualization=OFF \
|
||||
-DBUILD_MODULE_ApplicationFramework=OFF \
|
||||
-DBUILD_MODULE_DataExchange=ON \
|
||||
-DBUILD_MODULE_Draw=OFF \
|
||||
-DBUILD_ADDITIONAL_TOOLKITS="TKBO;TKDESTEP" \
|
||||
-DBUILD_DOC_Overview=OFF \
|
||||
-DBUILD_Inspector=OFF \
|
||||
-DBUILD_SAMPLES_QT=OFF \
|
||||
-DBUILD_USE_PCH=OFF \
|
||||
-DUSE_FREETYPE=OFF \
|
||||
-DUSE_TK=OFF \
|
||||
-DUSE_TBB=OFF \
|
||||
-DUSE_RAPIDJSON=OFF
|
||||
|
||||
cmake --build "${OCCT_BUILD_DIR}" --target \
|
||||
TKernel TKMath TKG2d TKG3d TKGeomBase TKGeomAlgo TKShHealing TKPrim TKTopAlgo TKBRep TKBO \
|
||||
TKDE TKXSBase TKDESTEP \
|
||||
--parallel "${JOBS}"
|
||||
mkdir -p "${DIST_DIR}"
|
||||
|
||||
em++ "${ROOT_DIR}/native/occt-history/occt_history.cpp" \
|
||||
-I"${OCCT_BUILD_DIR}/include/opencascade" \
|
||||
-I"${OCCT_SOURCE_DIR}/src" \
|
||||
-std=c++17 -O2 -fexceptions --bind \
|
||||
-Wl,--start-group \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKBO.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKDESTEP.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKXSBase.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKDE.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKBRep.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKTopAlgo.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKPrim.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKShHealing.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKGeomAlgo.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKGeomBase.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKG3d.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKG2d.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKMath.a" \
|
||||
"${OCCT_BUILD_DIR}/lin32/clang/lib/libTKernel.a" \
|
||||
-Wl,--end-group \
|
||||
-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker,node \
|
||||
-sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=134217728 -sMAXIMUM_MEMORY=2147483648 \
|
||||
-sNO_EXIT_RUNTIME=1 \
|
||||
-o "${DIST_DIR}/bitbybit-occt-history.js"
|
||||
|
||||
cp "${ROOT_DIR}/native/occt-history/package.json" "${DIST_DIR}/package.json"
|
||||
mkdir -p "${PUBLIC_DIR}"
|
||||
cp "${DIST_DIR}/bitbybit-occt-history.js" "${PUBLIC_DIR}/bitbybit-occt-history.js"
|
||||
cp "${DIST_DIR}/bitbybit-occt-history.wasm" "${PUBLIC_DIR}/bitbybit-occt-history.wasm"
|
||||
sha256sum "${DIST_DIR}/bitbybit-occt-history.js" "${DIST_DIR}/bitbybit-occt-history.wasm"
|
||||
247
native/occt-history/occt_history.cpp
Normal file
247
native/occt-history/occt_history.cpp
Normal file
@@ -0,0 +1,247 @@
|
||||
#include <BRepAlgoAPI_Common.hxx>
|
||||
#include <BRepAlgoAPI_Cut.hxx>
|
||||
#include <BRepAlgoAPI_Fuse.hxx>
|
||||
#include <BRepPrimAPI_MakeBox.hxx>
|
||||
#include <Standard_Version.hxx>
|
||||
#include <STEPControl_Reader.hxx>
|
||||
#include <STEPControl_Writer.hxx>
|
||||
#include <STEPControl_StepModelType.hxx>
|
||||
#include <TopAbs_ShapeEnum.hxx>
|
||||
#include <TopExp_Explorer.hxx>
|
||||
#include <TopoDS_Shape.hxx>
|
||||
|
||||
#include <emscripten/bind.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
struct HistoryRecord
|
||||
{
|
||||
std::string relation;
|
||||
std::string kind;
|
||||
int sourceIndex = -1;
|
||||
int resultIndex = -1;
|
||||
};
|
||||
|
||||
std::string kindName(const TopAbs_ShapeEnum theKind)
|
||||
{
|
||||
switch (theKind)
|
||||
{
|
||||
case TopAbs_VERTEX: return "vertex";
|
||||
case TopAbs_EDGE: return "edge";
|
||||
case TopAbs_WIRE: return "wire";
|
||||
case TopAbs_FACE: return "face";
|
||||
case TopAbs_SHELL: return "shell";
|
||||
case TopAbs_SOLID: return "solid";
|
||||
case TopAbs_COMPSOLID: return "compsolid";
|
||||
case TopAbs_COMPOUND: return "compound";
|
||||
default: return "shape";
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<TopoDS_Shape> subShapes(const TopoDS_Shape& theShape, const TopAbs_ShapeEnum theKind)
|
||||
{
|
||||
std::vector<TopoDS_Shape> aShapes;
|
||||
for (TopExp_Explorer anExplorer(theShape, theKind); anExplorer.More(); anExplorer.Next())
|
||||
{
|
||||
aShapes.push_back(anExplorer.Current());
|
||||
}
|
||||
return aShapes;
|
||||
}
|
||||
|
||||
std::string writeStep(const TopoDS_Shape& theShape);
|
||||
|
||||
int resultIndex(const TopoDS_Shape& theShape,
|
||||
const TopoDS_Shape& theResult,
|
||||
const TopAbs_ShapeEnum theKind)
|
||||
{
|
||||
int anIndex = 0;
|
||||
for (TopExp_Explorer anExplorer(theResult, theKind); anExplorer.More(); anExplorer.Next(), ++anIndex)
|
||||
{
|
||||
if (anExplorer.Current().IsSame(theShape))
|
||||
{
|
||||
return anIndex;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
template <typename TAlgorithm>
|
||||
emscripten::val runBoolean(TAlgorithm& theAlgorithm,
|
||||
const TopoDS_Shape& theObject,
|
||||
const TopoDS_Shape& theTool)
|
||||
{
|
||||
theAlgorithm.Build();
|
||||
if (!theAlgorithm.IsDone() || theAlgorithm.HasErrors())
|
||||
{
|
||||
throw std::runtime_error("OCCT boolean operation failed while collecting native history.");
|
||||
}
|
||||
|
||||
const TopoDS_Shape aResult = theAlgorithm.Shape();
|
||||
emscripten::val aRecords = emscripten::val::array();
|
||||
const TopAbs_ShapeEnum aKinds[] = {TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE};
|
||||
const TopoDS_Shape aSources[] = {theObject, theTool};
|
||||
const char* aSourceNames[] = {"object", "tool"};
|
||||
unsigned int aRecordIndex = 0;
|
||||
|
||||
for (int aSource = 0; aSource < 2; ++aSource)
|
||||
{
|
||||
for (const TopAbs_ShapeEnum aKind : aKinds)
|
||||
{
|
||||
const auto aSubShapes = subShapes(aSources[aSource], aKind);
|
||||
for (std::size_t aSourceIndex = 0; aSourceIndex < aSubShapes.size(); ++aSourceIndex)
|
||||
{
|
||||
const TopoDS_Shape& anInput = aSubShapes[aSourceIndex];
|
||||
const auto& aModified = theAlgorithm.Modified(anInput);
|
||||
for (NCollection_List<TopoDS_Shape>::Iterator anIterator(aModified); anIterator.More(); anIterator.Next())
|
||||
{
|
||||
const int anOutputIndex = resultIndex(anIterator.Value(), aResult, aKind);
|
||||
if (anOutputIndex < 0) continue;
|
||||
emscripten::val aRecord = emscripten::val::object();
|
||||
aRecord.set("relation", "modified");
|
||||
aRecord.set("source", aSourceNames[aSource]);
|
||||
aRecord.set("kind", kindName(aKind));
|
||||
aRecord.set("sourceIndex", static_cast<int>(aSourceIndex));
|
||||
aRecord.set("resultIndex", anOutputIndex);
|
||||
aRecords.set(aRecordIndex++, aRecord);
|
||||
}
|
||||
const auto& aGenerated = theAlgorithm.Generated(anInput);
|
||||
for (NCollection_List<TopoDS_Shape>::Iterator anIterator(aGenerated); anIterator.More(); anIterator.Next())
|
||||
{
|
||||
const int anOutputIndex = resultIndex(anIterator.Value(), aResult, aKind);
|
||||
if (anOutputIndex < 0) continue;
|
||||
emscripten::val aRecord = emscripten::val::object();
|
||||
aRecord.set("relation", "generated");
|
||||
aRecord.set("source", aSourceNames[aSource]);
|
||||
aRecord.set("kind", kindName(aKind));
|
||||
aRecord.set("sourceIndex", static_cast<int>(aSourceIndex));
|
||||
aRecord.set("resultIndex", anOutputIndex);
|
||||
aRecords.set(aRecordIndex++, aRecord);
|
||||
}
|
||||
if (theAlgorithm.IsDeleted(anInput))
|
||||
{
|
||||
emscripten::val aRecord = emscripten::val::object();
|
||||
aRecord.set("relation", "deleted");
|
||||
aRecord.set("source", aSourceNames[aSource]);
|
||||
aRecord.set("kind", kindName(aKind));
|
||||
aRecord.set("sourceIndex", static_cast<int>(aSourceIndex));
|
||||
aRecord.set("resultIndex", -1);
|
||||
aRecords.set(aRecordIndex++, aRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emscripten::val aResponse = emscripten::val::object();
|
||||
aResponse.set("provider", "occt-native");
|
||||
aResponse.set("occtVersion", OCC_VERSION_COMPLETE);
|
||||
aResponse.set("result", aResult);
|
||||
aResponse.set("resultStep", writeStep(aResult));
|
||||
aResponse.set("records", aRecords);
|
||||
aResponse.set("hasModified", theAlgorithm.HasModified());
|
||||
aResponse.set("hasGenerated", theAlgorithm.HasGenerated());
|
||||
aResponse.set("hasDeleted", theAlgorithm.HasDeleted());
|
||||
return aResponse;
|
||||
}
|
||||
|
||||
TopoDS_Shape makeBox(const double theX, const double theY, const double theZ)
|
||||
{
|
||||
if (theX <= 0.0 || theY <= 0.0 || theZ <= 0.0)
|
||||
{
|
||||
throw std::invalid_argument("Box dimensions must be positive.");
|
||||
}
|
||||
return BRepPrimAPI_MakeBox(theX, theY, theZ).Shape();
|
||||
}
|
||||
|
||||
std::string occtVersion()
|
||||
{
|
||||
return OCC_VERSION_COMPLETE;
|
||||
}
|
||||
|
||||
emscripten::val booleanHistory(const TopoDS_Shape& theObject,
|
||||
const TopoDS_Shape& theTool,
|
||||
const std::string& theOperation)
|
||||
{
|
||||
if (theOperation == "fuse")
|
||||
{
|
||||
BRepAlgoAPI_Fuse anAlgorithm(theObject, theTool);
|
||||
return runBoolean(anAlgorithm, theObject, theTool);
|
||||
}
|
||||
if (theOperation == "cut")
|
||||
{
|
||||
BRepAlgoAPI_Cut anAlgorithm(theObject, theTool);
|
||||
return runBoolean(anAlgorithm, theObject, theTool);
|
||||
}
|
||||
if (theOperation == "common")
|
||||
{
|
||||
BRepAlgoAPI_Common anAlgorithm(theObject, theTool);
|
||||
return runBoolean(anAlgorithm, theObject, theTool);
|
||||
}
|
||||
throw std::invalid_argument("Unsupported Boolean operation. Expected fuse, cut or common.");
|
||||
}
|
||||
|
||||
TopoDS_Shape readStep(const std::string& theText)
|
||||
{
|
||||
if (theText.empty())
|
||||
{
|
||||
throw std::invalid_argument("STEP text must not be empty.");
|
||||
}
|
||||
std::istringstream aStream(theText);
|
||||
STEPControl_Reader aReader;
|
||||
const IFSelect_ReturnStatus aStatus = aReader.ReadStream("bitbybit.step", aStream);
|
||||
if (aStatus != IFSelect_RetDone || aReader.NbRootsForTransfer() <= 0 || !aReader.TransferRoots())
|
||||
{
|
||||
throw std::runtime_error("OCCT could not read and transfer the supplied STEP text.");
|
||||
}
|
||||
const TopoDS_Shape aShape = aReader.OneShape();
|
||||
if (aShape.IsNull())
|
||||
{
|
||||
throw std::runtime_error("OCCT STEP transfer produced a null shape.");
|
||||
}
|
||||
return aShape;
|
||||
}
|
||||
|
||||
std::string writeStep(const TopoDS_Shape& theShape)
|
||||
{
|
||||
if (theShape.IsNull())
|
||||
{
|
||||
throw std::invalid_argument("Cannot serialize a null OCCT shape.");
|
||||
}
|
||||
STEPControl_Writer aWriter;
|
||||
if (aWriter.Transfer(theShape, STEPControl_AsIs) != IFSelect_RetDone)
|
||||
{
|
||||
throw std::runtime_error("OCCT could not transfer the shape to STEP.");
|
||||
}
|
||||
std::ostringstream aStream;
|
||||
if (aWriter.WriteStream(aStream) != IFSelect_RetDone)
|
||||
{
|
||||
throw std::runtime_error("OCCT could not write the shape to STEP.");
|
||||
}
|
||||
return aStream.str();
|
||||
}
|
||||
|
||||
emscripten::val booleanHistoryFromStep(const std::string& theObjectStep,
|
||||
const std::string& theToolStep,
|
||||
const std::string& theOperation)
|
||||
{
|
||||
return booleanHistory(readStep(theObjectStep), readStep(theToolStep), theOperation);
|
||||
}
|
||||
}
|
||||
|
||||
EMSCRIPTEN_BINDINGS(bitbybit_occt_history)
|
||||
{
|
||||
emscripten::class_<TopoDS_Shape>("TopoDS_Shape")
|
||||
.constructor<>()
|
||||
.function("isNull", &TopoDS_Shape::IsNull)
|
||||
.function("shapeType", &TopoDS_Shape::ShapeType)
|
||||
.function("isSame", &TopoDS_Shape::IsSame);
|
||||
emscripten::function("occtVersion", &occtVersion);
|
||||
emscripten::function("makeBox", &makeBox);
|
||||
emscripten::function("booleanHistory", &booleanHistory);
|
||||
emscripten::function("shapeToStep", &writeStep);
|
||||
emscripten::function("booleanHistoryFromStep", &booleanHistoryFromStep);
|
||||
}
|
||||
3
native/occt-history/package.json
Normal file
3
native/occt-history/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
22
native/occt-history/smoke-test.mjs
Normal file
22
native/occt-history/smoke-test.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import createOcctHistory from './dist/bitbybit-occt-history.js'
|
||||
|
||||
const occt = await createOcctHistory()
|
||||
assert.match(occt.occtVersion(), /^8\./)
|
||||
const object = occt.makeBox(10, 10, 10)
|
||||
const tool = occt.makeBox(5, 5, 5)
|
||||
const response = occt.booleanHistory(object, tool, 'cut')
|
||||
const objectStep = occt.shapeToStep(object)
|
||||
const toolStep = occt.shapeToStep(tool)
|
||||
assert.match(objectStep, /^ISO-10303-21;/)
|
||||
assert.match(toolStep, /^ISO-10303-21;/)
|
||||
const serializedResponse = occt.booleanHistoryFromStep(objectStep, toolStep, 'cut')
|
||||
assert.equal(response.provider, 'occt-native')
|
||||
assert.equal(response.records.every((record) => ['generated', 'modified', 'deleted'].includes(record.relation)), true)
|
||||
assert.equal(typeof response.hasModified, 'boolean')
|
||||
assert.equal(typeof response.hasGenerated, 'boolean')
|
||||
assert.equal(typeof response.hasDeleted, 'boolean')
|
||||
assert.equal(serializedResponse.provider, 'occt-native')
|
||||
assert.equal(serializedResponse.records.length > 0, true)
|
||||
assert.match(serializedResponse.resultStep, /^ISO-10303-21;/)
|
||||
console.log(JSON.stringify({ occtVersion: response.occtVersion, recordCount: response.records.length, hasModified: response.hasModified, hasGenerated: response.hasGenerated, hasDeleted: response.hasDeleted }, null, 2))
|
||||
Reference in New Issue
Block a user