Work on closed sliced surfaces
This commit is contained in:
parent
20d99b5815
commit
c33a39944a
5 changed files with 285 additions and 20 deletions
|
@ -1,16 +1,27 @@
|
||||||
import {
|
import {
|
||||||
|
BufferAttribute,
|
||||||
|
BufferGeometry,
|
||||||
DoubleSide,
|
DoubleSide,
|
||||||
|
Group,
|
||||||
Mesh,
|
Mesh,
|
||||||
MeshBasicMaterial,
|
MeshBasicMaterial,
|
||||||
|
MeshStandardMaterial,
|
||||||
Object3DEventMap,
|
Object3DEventMap,
|
||||||
PerspectiveCamera,
|
PerspectiveCamera,
|
||||||
Plane,
|
Plane,
|
||||||
PlaneGeometry,
|
PlaneGeometry,
|
||||||
|
Scene,
|
||||||
|
Vector2,
|
||||||
Vector3,
|
Vector3,
|
||||||
WebGLRenderer,
|
WebGLRenderer,
|
||||||
} from "three";
|
} from "three";
|
||||||
import { DragControls, OrbitControls } from "three/examples/jsm/Addons.js";
|
import {
|
||||||
|
ConvexGeometry,
|
||||||
|
DragControls,
|
||||||
|
OrbitControls,
|
||||||
|
} from "three/examples/jsm/Addons.js";
|
||||||
import { Extent } from "./build-scene";
|
import { Extent } from "./build-scene";
|
||||||
|
import earcut from "earcut";
|
||||||
|
|
||||||
enum Orientation {
|
enum Orientation {
|
||||||
X = "x",
|
X = "x",
|
||||||
|
@ -27,7 +38,9 @@ export function createClippingPlanes(
|
||||||
renderer: WebGLRenderer,
|
renderer: WebGLRenderer,
|
||||||
camera: PerspectiveCamera,
|
camera: PerspectiveCamera,
|
||||||
orbitControls: OrbitControls,
|
orbitControls: OrbitControls,
|
||||||
extent: Extent
|
extent: Extent,
|
||||||
|
meshes: Mesh[],
|
||||||
|
scene: Scene
|
||||||
) {
|
) {
|
||||||
const planesData = [
|
const planesData = [
|
||||||
{
|
{
|
||||||
|
@ -139,6 +152,13 @@ export function createClippingPlanes(
|
||||||
dragControls.addEventListener("dragstart", () => {
|
dragControls.addEventListener("dragstart", () => {
|
||||||
// Disable OrbitControls when dragging starts
|
// Disable OrbitControls when dragging starts
|
||||||
orbitControls.enabled = false;
|
orbitControls.enabled = false;
|
||||||
|
|
||||||
|
// Remove existing cap meshes
|
||||||
|
let capMeshGroup = scene.getObjectByName("cap-mesh-group");
|
||||||
|
while (capMeshGroup) {
|
||||||
|
scene.remove(capMeshGroup);
|
||||||
|
capMeshGroup = scene.getObjectByName("cap-mesh-group");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dragControls.addEventListener("dragend", () => {
|
dragControls.addEventListener("dragend", () => {
|
||||||
|
@ -221,6 +241,24 @@ export function createClippingPlanes(
|
||||||
// Resize other meshes
|
// Resize other meshes
|
||||||
resizeMeshes(Orientation.X, newX, planeMeshMap as PlaneMeshMap, extent);
|
resizeMeshes(Orientation.X, newX, planeMeshMap as PlaneMeshMap, extent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove existing cap meshes
|
||||||
|
let capMeshGroup = scene.getObjectByName("cap-mesh-group");
|
||||||
|
while (capMeshGroup) {
|
||||||
|
scene.remove(capMeshGroup);
|
||||||
|
capMeshGroup = scene.getObjectByName("cap-mesh-group");
|
||||||
|
}
|
||||||
|
|
||||||
|
const capMeshes = generateCapMeshes(meshes, plane);
|
||||||
|
|
||||||
|
if (capMeshes.length > 0) {
|
||||||
|
// Add new cap meshes
|
||||||
|
const newCapMeshGroup = new Group();
|
||||||
|
|
||||||
|
newCapMeshGroup.add(...capMeshes);
|
||||||
|
newCapMeshGroup.name = "cap-mesh-group";
|
||||||
|
scene.add(newCapMeshGroup);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { planeMeshes, planes };
|
return { planeMeshes, planes };
|
||||||
|
@ -312,3 +350,213 @@ function resizeMeshes(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract contour and generate cap
|
||||||
|
function generateCapMeshes(meshes: Mesh[], plane: Plane) {
|
||||||
|
const capMeshes: Mesh[] = [];
|
||||||
|
for (let mesh of meshes) {
|
||||||
|
const position = mesh.geometry.attributes.position.array;
|
||||||
|
const indices = mesh.geometry.index ? mesh.geometry.index.array : null;
|
||||||
|
const edges: Array<[Vector3, Vector3]> = [];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < (indices ? indices.length : position.length / 3);
|
||||||
|
i += 3
|
||||||
|
) {
|
||||||
|
const i1 = indices ? indices[i] * 3 : i * 3;
|
||||||
|
const i2 = indices ? indices[i + 1] * 3 : (i + 1) * 3;
|
||||||
|
const i3 = indices ? indices[i + 2] * 3 : (i + 2) * 3;
|
||||||
|
|
||||||
|
const v1 = new Vector3(position[i1], position[i1 + 1], position[i1 + 2]);
|
||||||
|
const v2 = new Vector3(position[i2], position[i2 + 1], position[i2 + 2]);
|
||||||
|
const v3 = new Vector3(position[i3], position[i3 + 1], position[i3 + 2]);
|
||||||
|
|
||||||
|
// Check if the triangle is cut by the plane
|
||||||
|
const d1 = plane.distanceToPoint(v1);
|
||||||
|
const d2 = plane.distanceToPoint(v2);
|
||||||
|
const d3 = plane.distanceToPoint(v3);
|
||||||
|
|
||||||
|
// Compute intersection points
|
||||||
|
const intersections = [];
|
||||||
|
|
||||||
|
if (d1 * d2 < 0) intersections.push(intersectEdge(v1, v2, d1, d2));
|
||||||
|
if (d2 * d3 < 0) intersections.push(intersectEdge(v2, v3, d2, d3));
|
||||||
|
if (d3 * d1 < 0) intersections.push(intersectEdge(v3, v1, d3, d1));
|
||||||
|
|
||||||
|
if (intersections.length === 2) {
|
||||||
|
edges.push([intersections[0], intersections[1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const polygons: Vector3[][] = buildPolygons(edges);
|
||||||
|
|
||||||
|
const material = new MeshStandardMaterial({
|
||||||
|
color: (mesh.material as MeshStandardMaterial).color,
|
||||||
|
side: DoubleSide,
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: -1,
|
||||||
|
polygonOffsetUnits: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const localMeshes = polygons.map((polygon) => {
|
||||||
|
const geometry = triangulatePolygon(polygon, plane);
|
||||||
|
|
||||||
|
const capMesh = new Mesh(geometry, material);
|
||||||
|
|
||||||
|
// Offset mesh to avoid flickering
|
||||||
|
const offset = 10;
|
||||||
|
const normal = plane.normal.clone().multiplyScalar(offset);
|
||||||
|
|
||||||
|
const positionAttr = capMesh.geometry.attributes.position;
|
||||||
|
for (let i = 0; i < positionAttr.count; i++) {
|
||||||
|
const x = positionAttr.getX(i) - normal.x;
|
||||||
|
const y = positionAttr.getY(i) - normal.y;
|
||||||
|
const z = positionAttr.getZ(i) - normal.z;
|
||||||
|
positionAttr.setXYZ(i, x, y, z);
|
||||||
|
}
|
||||||
|
positionAttr.needsUpdate = true;
|
||||||
|
|
||||||
|
return capMesh;
|
||||||
|
});
|
||||||
|
|
||||||
|
capMeshes.push(...localMeshes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return capMeshes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build polygons by grouping connected intersection edges
|
||||||
|
function buildPolygons(edges: Array<[Vector3, Vector3]>): Vector3[][] {
|
||||||
|
const polygons: Vector3[][] = [];
|
||||||
|
const edgeMap = new Map<string, [Vector3, Vector3]>();
|
||||||
|
|
||||||
|
// Populate the edgeMap for fast lookups
|
||||||
|
for (const [v1, v2] of edges) {
|
||||||
|
edgeMap.set(`${v1.x},${v1.y},${v1.z}-${v2.x},${v2.y},${v2.z}`, [v1, v2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (edgeMap.size > 0) {
|
||||||
|
const polygon: Vector3[] = [];
|
||||||
|
const [start, end] = edgeMap.values().next().value; // Take any edge as a start
|
||||||
|
edgeMap.delete(
|
||||||
|
`${start.x},${start.y},${start.z}-${end.x},${end.y},${end.z}`
|
||||||
|
);
|
||||||
|
|
||||||
|
polygon.push(start, end);
|
||||||
|
let lastPoint = end;
|
||||||
|
while (true) {
|
||||||
|
let foundNextEdge = false;
|
||||||
|
|
||||||
|
for (const [key, [v1, v2]] of edgeMap) {
|
||||||
|
// Check if v1 or v2 is the last point to continue the polygon
|
||||||
|
if (lastPoint.distanceTo(v1) < 1e-6) {
|
||||||
|
polygon.push(v2);
|
||||||
|
lastPoint = v2;
|
||||||
|
edgeMap.delete(key);
|
||||||
|
foundNextEdge = true;
|
||||||
|
break;
|
||||||
|
} else if (lastPoint.distanceTo(v2) < 1e-6) {
|
||||||
|
polygon.push(v1);
|
||||||
|
lastPoint = v1;
|
||||||
|
edgeMap.delete(key);
|
||||||
|
foundNextEdge = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundNextEdge) break; // Stop if no connected edge is found
|
||||||
|
}
|
||||||
|
|
||||||
|
if (polygon.length >= 3) polygons.push(polygon); // Ensure valid polygon with at least 3 vertices
|
||||||
|
}
|
||||||
|
|
||||||
|
return polygons;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to triangulate the sliced polygon vertices
|
||||||
|
function triangulatePolygon(vertices: Vector3[], plane: Plane) {
|
||||||
|
// Project vertices to the plane
|
||||||
|
const projectedVertices = projectVerticesToPlane(vertices, plane);
|
||||||
|
|
||||||
|
// Sort vertices in counter-clockwise order
|
||||||
|
const sortedVertices = sortVertices(projectedVertices);
|
||||||
|
|
||||||
|
// Convert the sorted 2D vertices back to flat array
|
||||||
|
const flatVertices: number[] = [];
|
||||||
|
sortedVertices.forEach((v) => {
|
||||||
|
flatVertices.push(v.x, v.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use earcut to triangulate the 2D polygon (returns an array of indices)
|
||||||
|
const indices = earcut(flatVertices);
|
||||||
|
|
||||||
|
// Create geometry for the triangulated result
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
const positions: number[] = [];
|
||||||
|
|
||||||
|
vertices.forEach((v) => {
|
||||||
|
positions.push(v.x, v.y, v.z);
|
||||||
|
});
|
||||||
|
|
||||||
|
geometry.setAttribute(
|
||||||
|
"position",
|
||||||
|
new BufferAttribute(new Float32Array(positions), 3)
|
||||||
|
);
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectVerticesToPlane(vertices: Vector3[], plane: Plane) {
|
||||||
|
// Choose a reference point on the plane (e.g., centroid)
|
||||||
|
const planeOrigin = vertices
|
||||||
|
.reduce((sum, v) => sum.add(v), new Vector3())
|
||||||
|
.divideScalar(vertices.length);
|
||||||
|
|
||||||
|
// Define local 2D coordinate system on the plane
|
||||||
|
const N = plane.normal.clone().normalize();
|
||||||
|
let T = new Vector3(1, 0, 0);
|
||||||
|
|
||||||
|
// Ensure T is not parallel to N
|
||||||
|
if (Math.abs(N.dot(T)) > 0.9) {
|
||||||
|
T.set(0, 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const U = new Vector3().crossVectors(N, T).normalize(); // First tangent
|
||||||
|
const V = new Vector3().crossVectors(N, U).normalize(); // Second tangent
|
||||||
|
|
||||||
|
// Project each vertex to 2D space in the plane
|
||||||
|
return vertices.map((v) => {
|
||||||
|
const relativePos = v.clone().sub(planeOrigin);
|
||||||
|
return new Vector2(relativePos.dot(U), relativePos.dot(V));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortVertices(vertices: Vector2[]) {
|
||||||
|
const centroid = new Vector2(0, 0);
|
||||||
|
|
||||||
|
// Compute the centroid of the vertices
|
||||||
|
vertices.forEach((v) => centroid.add(v));
|
||||||
|
centroid.divideScalar(vertices.length);
|
||||||
|
|
||||||
|
// Sort vertices by the angle with the centroid
|
||||||
|
vertices.sort((a, b) => {
|
||||||
|
return (
|
||||||
|
Math.atan2(a.y - centroid.y, a.x - centroid.x) -
|
||||||
|
Math.atan2(b.y - centroid.y, b.x - centroid.x)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return vertices;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to find the intersection point between an edge and a plane
|
||||||
|
function intersectEdge(v1: Vector3, v2: Vector3, d1: number, d2: number) {
|
||||||
|
const t = d1 / (d1 - d2);
|
||||||
|
return new Vector3(
|
||||||
|
v1.x + t * (v2.x - v1.x),
|
||||||
|
v1.y + t * (v2.y - v1.y),
|
||||||
|
v1.z + t * (v2.z - v1.z)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -2,14 +2,9 @@ import {
|
||||||
BufferAttribute,
|
BufferAttribute,
|
||||||
BufferGeometry,
|
BufferGeometry,
|
||||||
DoubleSide,
|
DoubleSide,
|
||||||
FrontSide,
|
|
||||||
Group,
|
|
||||||
Mesh,
|
Mesh,
|
||||||
MeshStandardMaterial,
|
MeshStandardMaterial,
|
||||||
Plane,
|
Plane,
|
||||||
PlaneHelper,
|
|
||||||
Scene,
|
|
||||||
Vector3,
|
|
||||||
} from "three";
|
} from "three";
|
||||||
|
|
||||||
import { uniforms } from "./uniforms";
|
import { uniforms } from "./uniforms";
|
||||||
|
@ -26,7 +21,7 @@ interface MappedFeature {
|
||||||
preview: { legend_color: string; legend_text: string };
|
preview: { legend_color: string; legend_text: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildMesh(layerData: MappedFeature, clippingPlanes: Plane[]) {
|
async function buildMesh(layerData: MappedFeature) {
|
||||||
const color = `#${layerData.preview.legend_color}`;
|
const color = `#${layerData.preview.legend_color}`;
|
||||||
const name = layerData.preview.legend_text;
|
const name = layerData.preview.legend_text;
|
||||||
const geomId = layerData.featuregeom_id.toString();
|
const geomId = layerData.featuregeom_id.toString();
|
||||||
|
@ -53,7 +48,6 @@ async function buildMesh(layerData: MappedFeature, clippingPlanes: Plane[]) {
|
||||||
flatShading: true,
|
flatShading: true,
|
||||||
side: DoubleSide,
|
side: DoubleSide,
|
||||||
wireframe: false,
|
wireframe: false,
|
||||||
clippingPlanes: clippingPlanes,
|
|
||||||
clipIntersection: false,
|
clipIntersection: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -75,15 +69,12 @@ async function buildMesh(layerData: MappedFeature, clippingPlanes: Plane[]) {
|
||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildMeshes(
|
export async function buildMeshes(mappedFeatures: MappedFeature[]) {
|
||||||
mappedFeatures: MappedFeature[],
|
|
||||||
clippingPlanes: Plane[]
|
|
||||||
) {
|
|
||||||
const meshes = [];
|
const meshes = [];
|
||||||
for (let i = 0; i < mappedFeatures.length; i++) {
|
for (let i = 0; i < mappedFeatures.length; i++) {
|
||||||
const layerData = mappedFeatures[i];
|
const layerData = mappedFeatures[i];
|
||||||
if (layerData.name !== "Topography") {
|
if (layerData.name !== "Topography") {
|
||||||
const mesh = await buildMesh(layerData, clippingPlanes);
|
const mesh = await buildMesh(layerData);
|
||||||
meshes.push(mesh);
|
meshes.push(mesh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,19 +25,30 @@ export async function init(container: HTMLElement) {
|
||||||
extent
|
extent
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create the 3D model
|
||||||
|
const meshes = await buildMeshes(mappedFeatures);
|
||||||
|
const model = new Group();
|
||||||
|
model.add(...meshes);
|
||||||
|
model.name = "3d-model";
|
||||||
|
scene.add(model);
|
||||||
|
|
||||||
|
// Create the clipping planes and add them to the scene
|
||||||
const { planeMeshes, planes } = createClippingPlanes(
|
const { planeMeshes, planes } = createClippingPlanes(
|
||||||
renderer,
|
renderer,
|
||||||
camera,
|
camera,
|
||||||
controls,
|
controls,
|
||||||
extent
|
extent,
|
||||||
|
meshes,
|
||||||
|
scene
|
||||||
);
|
);
|
||||||
scene.add(...planeMeshes);
|
scene.add(...planeMeshes);
|
||||||
|
|
||||||
const meshes = await buildMeshes(mappedFeatures, planes);
|
// Add clipping planes to the meshes
|
||||||
const mappedFeaturesGroup = new Group();
|
for (let mesh of meshes) {
|
||||||
mappedFeaturesGroup.add(...meshes);
|
mesh.material.clippingPlanes = planes;
|
||||||
scene.add(mappedFeaturesGroup);
|
}
|
||||||
|
|
||||||
|
// Add a coordinate grid to the scene
|
||||||
const { gridHelper, annotations } = buildGrid(extent);
|
const { gridHelper, annotations } = buildGrid(extent);
|
||||||
const annotationsGroup = new Group();
|
const annotationsGroup = new Group();
|
||||||
annotationsGroup.add(...annotations);
|
annotationsGroup.add(...annotations);
|
||||||
|
|
13
package-lock.json
generated
13
package-lock.json
generated
|
@ -8,6 +8,7 @@
|
||||||
"name": "3d-viewer",
|
"name": "3d-viewer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"earcut": "^3.0.1",
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"proj4": "^2.15.0",
|
"proj4": "^2.15.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
@ -16,6 +17,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@types/earcut": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
@ -854,6 +856,12 @@
|
||||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/earcut": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
|
@ -1897,6 +1905,11 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw=="
|
||||||
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"earcut": "^3.0.1",
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"proj4": "^2.15.0",
|
"proj4": "^2.15.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
@ -17,6 +18,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@types/earcut": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
@ -27,4 +29,4 @@
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
editor.link_modal.header
Reference in a new issue