@xom11/whiteboard 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +51 -1
  2. package/dist/chunk-74VEEZBV.mjs +619 -0
  3. package/dist/chunk-74VEEZBV.mjs.map +1 -0
  4. package/dist/chunk-DU2NFHRR.mjs +103 -0
  5. package/dist/chunk-DU2NFHRR.mjs.map +1 -0
  6. package/dist/{chunk-SHFOGORM.mjs → chunk-DU3RHKT5.mjs} +4 -4
  7. package/dist/{chunk-SHFOGORM.mjs.map → chunk-DU3RHKT5.mjs.map} +1 -1
  8. package/dist/{chunk-HYXFHEDJ.mjs → chunk-IUVV52HO.mjs} +22 -7
  9. package/dist/chunk-IUVV52HO.mjs.map +1 -0
  10. package/dist/{chunk-BJX4YNA5.mjs → chunk-KEYZ5EZT.mjs} +26 -9
  11. package/dist/chunk-KEYZ5EZT.mjs.map +1 -0
  12. package/dist/{chunk-LPM4MM45.mjs → chunk-SBDMF4NQ.mjs} +3 -2
  13. package/dist/chunk-SBDMF4NQ.mjs.map +1 -0
  14. package/dist/{chunk-3SSQKRRO.mjs → chunk-ZVN356JZ.mjs} +4 -4
  15. package/dist/{chunk-3SSQKRRO.mjs.map → chunk-ZVN356JZ.mjs.map} +1 -1
  16. package/dist/geometry-2d.js +250 -218
  17. package/dist/geometry-2d.js.map +1 -1
  18. package/dist/geometry-2d.mjs +2 -2
  19. package/dist/geometry-3d.d.mts +1 -1
  20. package/dist/geometry-3d.d.ts +1 -1
  21. package/dist/geometry-3d.js +3276 -1201
  22. package/dist/geometry-3d.js.map +1 -1
  23. package/dist/geometry-3d.mjs +3 -2
  24. package/dist/graph-2d.js +360 -66
  25. package/dist/graph-2d.js.map +1 -1
  26. package/dist/graph-2d.mjs +2 -2
  27. package/dist/{host-2QGKMGCT.mjs → host-LZH2FZ2N.mjs} +3 -3
  28. package/dist/{host-2QGKMGCT.mjs.map → host-LZH2FZ2N.mjs.map} +1 -1
  29. package/dist/host-PIIDSMVE.mjs +3187 -0
  30. package/dist/host-PIIDSMVE.mjs.map +1 -0
  31. package/dist/{host-T2W6R6SO.mjs → host-VDNAJMLC.mjs} +221 -216
  32. package/dist/host-VDNAJMLC.mjs.map +1 -0
  33. package/dist/index.d.mts +6 -5
  34. package/dist/index.d.ts +6 -5
  35. package/dist/index.js +4365 -1821
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +246 -102
  38. package/dist/index.mjs.map +1 -1
  39. package/package.json +6 -6
  40. package/dist/chunk-BJX4YNA5.mjs.map +0 -1
  41. package/dist/chunk-DJTBZEAR.mjs +0 -25
  42. package/dist/chunk-DJTBZEAR.mjs.map +0 -1
  43. package/dist/chunk-HM7RIXJE.mjs +0 -331
  44. package/dist/chunk-HM7RIXJE.mjs.map +0 -1
  45. package/dist/chunk-HYXFHEDJ.mjs.map +0 -1
  46. package/dist/chunk-LPM4MM45.mjs.map +0 -1
  47. package/dist/host-T2W6R6SO.mjs.map +0 -1
  48. package/dist/host-XUFON6CQ.mjs +0 -1422
  49. package/dist/host-XUFON6CQ.mjs.map +0 -1
@@ -1,10 +1,30 @@
1
1
  "use client";
2
2
  'use strict';
3
3
 
4
- var react = require('react');
4
+ var React2 = require('react');
5
5
  var jsxRuntime = require('react/jsx-runtime');
6
6
  var reactDom = require('react-dom');
7
7
 
8
+ function _interopNamespace(e) {
9
+ if (e && e.__esModule) return e;
10
+ var n = Object.create(null);
11
+ if (e) {
12
+ Object.keys(e).forEach(function (k) {
13
+ if (k !== 'default') {
14
+ var d = Object.getOwnPropertyDescriptor(e, k);
15
+ Object.defineProperty(n, k, d.get ? d : {
16
+ enumerable: true,
17
+ get: function () { return e[k]; }
18
+ });
19
+ }
20
+ });
21
+ }
22
+ n.default = e;
23
+ return Object.freeze(n);
24
+ }
25
+
26
+ var React2__namespace = /*#__PURE__*/_interopNamespace(React2);
27
+
8
28
  var __defProp = Object.defineProperty;
9
29
  var __getOwnPropNames = Object.getOwnPropertyNames;
10
30
  var __esm = (fn, res) => function __init() {
@@ -19,7 +39,10 @@ var __export = (target, all) => {
19
39
  function isGeometry3DCustomData(data) {
20
40
  if (!data || typeof data !== "object") return false;
21
41
  const d = data;
22
- return d.kind === "geometry3d" && d.version === 1 && typeof d.jsonState === "string";
42
+ return d.kind === "geometry3d" && (d.version === 1 || d.version === 2) && typeof d.jsonState === "string";
43
+ }
44
+ function serializeBoard3D(state) {
45
+ return JSON.stringify(state);
23
46
  }
24
47
  function parseSerializedBoard3D(json) {
25
48
  const parsed = JSON.parse(json);
@@ -27,7 +50,7 @@ function parseSerializedBoard3D(json) {
27
50
  throw new Error("parseSerializedBoard3D: not an object");
28
51
  }
29
52
  const p = parsed;
30
- if (p.version !== 1) {
53
+ if (p.version !== 1 && p.version !== 2) {
31
54
  throw new Error(`parseSerializedBoard3D: unsupported version ${String(p.version)}`);
32
55
  }
33
56
  if (!Array.isArray(p.elements)) {
@@ -70,7 +93,7 @@ function paletteFor2(isDark) {
70
93
  axisZ: "#2d6dd6"
71
94
  };
72
95
  }
73
- var DEFAULT_VIEW3D, VIEW3D_ATTRS;
96
+ var DEFAULT_VIEW3D, VIEW3D_ATTRS, GROUND_PLANE_ATTRS, GROUND_PLANE_RANGE;
74
97
  var init_theme2 = __esm({
75
98
  "src/stamps/geometry-3d/editor/theme.ts"() {
76
99
  init_theme();
@@ -81,978 +104,2881 @@ var init_theme2 = __esm({
81
104
  };
82
105
  VIEW3D_ATTRS = (isDark) => {
83
106
  const p = paletteFor2(isDark);
107
+ const axisLabel = (color) => ({
108
+ strokeColor: color,
109
+ fontSize: 14,
110
+ offset: [10, 0]
111
+ });
84
112
  return {
85
113
  az: { slider: { visible: false }, point2: { visible: false } },
86
114
  el: { slider: { visible: false } },
87
115
  projection: "central",
88
- axesPosition: "border",
89
- xAxis: { strokeColor: p.axisX, lastArrow: { type: 2 } },
90
- yAxis: { strokeColor: p.axisY, lastArrow: { type: 2 } },
91
- zAxis: { strokeColor: p.axisZ, lastArrow: { type: 2 } }
116
+ // GeoGebra-style: axes pass through origin (0,0,0) instead of bbox border.
117
+ axesPosition: "center",
118
+ xAxis: {
119
+ strokeColor: p.axisX,
120
+ strokeWidth: 2,
121
+ lastArrow: { type: 2, size: 8 },
122
+ name: "x",
123
+ withLabel: true,
124
+ label: axisLabel(p.axisX)
125
+ },
126
+ yAxis: {
127
+ strokeColor: p.axisY,
128
+ strokeWidth: 2,
129
+ lastArrow: { type: 2, size: 8 },
130
+ name: "y",
131
+ withLabel: true,
132
+ label: axisLabel(p.axisY)
133
+ },
134
+ zAxis: {
135
+ strokeColor: p.axisZ,
136
+ strokeWidth: 2,
137
+ lastArrow: { type: 2, size: 8 },
138
+ name: "z",
139
+ withLabel: true,
140
+ label: axisLabel(p.axisZ)
141
+ },
142
+ // GeoGebra-style: hide ALL bbox wall planes; the XY ground plane is drawn
143
+ // explicitly at z=0 via the helper below (so it coincides with Ox/Oy).
144
+ xPlaneRear: { visible: false, mesh3d: { visible: false } },
145
+ yPlaneRear: { visible: false, mesh3d: { visible: false } },
146
+ zPlaneRear: { visible: false, mesh3d: { visible: false } }
92
147
  };
93
148
  };
149
+ GROUND_PLANE_ATTRS = (isDark) => ({
150
+ fillColor: isDark ? "#2a2a2a" : "#e6e6e6",
151
+ fillOpacity: isDark ? 0.5 : 0.55,
152
+ strokeColor: isDark ? "#3a3a3a" : "#cfcfcf",
153
+ strokeOpacity: 0.7,
154
+ strokeWidth: 1,
155
+ fixed: true,
156
+ highlight: false,
157
+ withLabel: false,
158
+ layer: 0
159
+ });
160
+ GROUND_PLANE_RANGE = [-3, 3];
94
161
  }
95
162
  });
96
163
 
97
- // src/stamps/geometry-3d/editor/handlers.ts
98
- function createHandlerContext(deps) {
99
- return {
100
- ...deps,
101
- pendingPoints: [],
102
- pendingFlags: {},
103
- pushedPointCoords: /* @__PURE__ */ new Map()
104
- };
164
+ // src/stamps/geometry-3d/editor/tools/handlers/_ensurePoint.ts
165
+ function hitToConstraint(hit) {
166
+ switch (hit.kind) {
167
+ case "onGround":
168
+ return { kind: "onGround", x: hit.world[0], y: hit.world[1] };
169
+ case "onAxis":
170
+ return { kind: "onAxis", axis: hit.axis, t: hit.t };
171
+ case "onPlane":
172
+ return { kind: "onPlane", planeId: hit.planeId, u: hit.u, v: hit.v };
173
+ case "onLine":
174
+ return { kind: "onLine", lineId: hit.lineId, t: hit.t };
175
+ case "onPolygon":
176
+ return { kind: "onPolygon", polygonId: hit.polygonId, u: hit.u, v: hit.v };
177
+ case "onSphere":
178
+ return { kind: "onSphere", sphereId: hit.sphereId, theta: hit.theta, phi: hit.phi };
179
+ default:
180
+ return null;
181
+ }
105
182
  }
106
- function refByPlaceholder(id) {
107
- return `@id:${id}`;
108
- }
109
- function createPoint3D(ctx, x, y, z, label) {
110
- const id = ctx.nextId();
111
- const attrs = { id, size: 3 };
112
- const ref = ctx.view.create("point3d", [x, y, z], attrs);
113
- ctx.objMap.set(id, ref);
114
- ctx.pushedPointCoords.set(id, [x, y, z]);
115
- ctx.pushLog({
116
- type: "point3d",
117
- parents: [x, y, z],
118
- attributes: attrs,
119
- id,
120
- label
121
- });
122
- return { id, ref, coords: [x, y, z] };
123
- }
124
- function resolvePoint(ctx, hit) {
125
- if (hit.existingPointId && ctx.objMap.has(hit.existingPointId)) {
126
- const stored = ctx.pushedPointCoords.get(hit.existingPointId);
127
- return {
128
- id: hit.existingPointId,
129
- ref: ctx.objMap.get(hit.existingPointId),
130
- coords: stored ?? [hit.x3, hit.y3, hit.z3]
131
- };
183
+ function ensurePoint(hit, scene) {
184
+ if (hit.kind === "existingPoint") return hit.pointId;
185
+ const c = hitToConstraint(hit);
186
+ if (!c) return null;
187
+ return scene.addPoint(c);
188
+ }
189
+ var init_ensurePoint = __esm({
190
+ "src/stamps/geometry-3d/editor/tools/handlers/_ensurePoint.ts"() {
132
191
  }
133
- return createPoint3D(ctx, hit.x3, hit.y3, hit.z3);
134
- }
135
- function finishPolygon(ctx, points, extraAttrs = {}) {
136
- const id = ctx.nextId();
137
- const refs = points.map((p) => p.ref);
138
- const attrs = { id, ...extraAttrs };
139
- const ref = ctx.view.create("polygon3d", [refs], attrs);
140
- ctx.objMap.set(id, ref);
141
- ctx.pushLog({
142
- type: "polygon3d",
143
- parents: [points.map((p) => refByPlaceholder(p.id))],
144
- attributes: attrs,
145
- id
146
- });
192
+ });
193
+
194
+ // src/stamps/geometry-3d/editor/tools/handlers/point.ts
195
+ function buildPoint(args, scene) {
196
+ const hit = args[0]?.hit;
197
+ if (!hit) return null;
198
+ if (hit.kind === "existingPoint") return hit.pointId;
199
+ const c = hitToConstraint(hit);
200
+ if (!c) return null;
201
+ return scene.addPoint(c);
147
202
  }
148
- function finishLineLike(ctx, elType, points, extraAttrs = {}) {
149
- const id = ctx.nextId();
150
- const refs = points.map((p) => p.ref);
151
- const attrs = { id, ...extraAttrs };
152
- const ref = ctx.view.create(elType, refs, attrs);
153
- ctx.objMap.set(id, ref);
154
- ctx.pushLog({
155
- type: elType,
156
- parents: points.map((p) => refByPlaceholder(p.id)),
157
- attributes: attrs,
158
- id
159
- });
203
+ var buildPointOnObject;
204
+ var init_point = __esm({
205
+ "src/stamps/geometry-3d/editor/tools/handlers/point.ts"() {
206
+ init_ensurePoint();
207
+ buildPointOnObject = buildPoint;
208
+ }
209
+ });
210
+
211
+ // src/stamps/geometry-3d/editor/tools/handlers/segment.ts
212
+ function buildSegment(args, scene) {
213
+ if (args.length < 2 || !args[0].hit || !args[1].hit) return null;
214
+ const p1 = ensurePoint(args[0].hit, scene);
215
+ const p2 = ensurePoint(args[1].hit, scene);
216
+ if (!p1 || !p2 || p1 === p2) return null;
217
+ return scene.addObject("segment", { p1, p2 });
160
218
  }
161
- function handleToolStep(ctx, tool, hit) {
162
- switch (tool) {
163
- case "move":
164
- return;
165
- case "point": {
166
- const coords = ctx.promptCoords("To\u1EA1 \u0111\u1ED9 \u0111i\u1EC3m (x, y, z)");
167
- if (!coords) return;
168
- createPoint3D(ctx, coords.x, coords.y, coords.z);
169
- ctx.notify();
170
- return;
171
- }
172
- case "segment":
173
- case "line": {
174
- const p = resolvePoint(ctx, hit);
175
- ctx.pendingPoints.push(p);
176
- if (ctx.pendingPoints.length === 2) {
177
- const lineColor = ctx.isDark ? "#9ecbff" : "#0066cc";
178
- const baseAttrs = {
179
- strokeColor: lineColor,
180
- strokeWidth: 2,
181
- visible: true,
182
- fixed: true
183
- };
184
- if (tool === "segment") {
185
- baseAttrs.straightFirst = false;
186
- baseAttrs.straightLast = false;
187
- }
188
- finishLineLike(ctx, "line3d", ctx.pendingPoints, baseAttrs);
189
- ctx.pendingPoints = [];
190
- }
191
- ctx.notify();
192
- return;
219
+ function buildLine(args, scene) {
220
+ if (args.length < 2 || !args[0].hit || !args[1].hit) return null;
221
+ const p1 = ensurePoint(args[0].hit, scene);
222
+ const p2 = ensurePoint(args[1].hit, scene);
223
+ if (!p1 || !p2 || p1 === p2) return null;
224
+ return scene.addObject("line", { p1, p2 });
225
+ }
226
+ function buildRay(args, scene) {
227
+ if (args.length < 2 || !args[0].hit || !args[1].hit) return null;
228
+ const origin = ensurePoint(args[0].hit, scene);
229
+ const through = ensurePoint(args[1].hit, scene);
230
+ if (!origin || !through || origin === through) return null;
231
+ return scene.addObject("ray", { origin, through });
232
+ }
233
+ function buildVector(args, scene) {
234
+ if (args.length < 2 || !args[0].hit || !args[1].hit) return null;
235
+ const from = ensurePoint(args[0].hit, scene);
236
+ const to = ensurePoint(args[1].hit, scene);
237
+ if (!from || !to || from === to) return null;
238
+ return scene.addObject("vector", { from, to });
239
+ }
240
+ var init_segment = __esm({
241
+ "src/stamps/geometry-3d/editor/tools/handlers/segment.ts"() {
242
+ init_ensurePoint();
243
+ }
244
+ });
245
+
246
+ // src/stamps/geometry-3d/editor/tools/handlers/polygon.ts
247
+ function buildPolygon(args, scene) {
248
+ const vertexArgs = args.filter((a) => a.step.type === "point");
249
+ const vertexIds = vertexArgs.map((a) => a.hit ? ensurePoint(a.hit, scene) : null).filter((x) => !!x);
250
+ if (vertexIds.length < 3) return null;
251
+ return scene.addObject("polygon", { vertices: vertexIds });
252
+ }
253
+ var init_polygon = __esm({
254
+ "src/stamps/geometry-3d/editor/tools/handlers/polygon.ts"() {
255
+ init_ensurePoint();
256
+ }
257
+ });
258
+
259
+ // src/stamps/geometry-3d/editor/scene/constraintMath.ts
260
+ function sub(a, b) {
261
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
262
+ }
263
+ function add(a, b) {
264
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
265
+ }
266
+ function scale(a, k) {
267
+ return [a[0] * k, a[1] * k, a[2] * k];
268
+ }
269
+ function dot(a, b) {
270
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
271
+ }
272
+ function cross(a, b) {
273
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
274
+ }
275
+ function norm(a) {
276
+ return Math.sqrt(dot(a, a));
277
+ }
278
+ function normalize(a) {
279
+ const n = norm(a);
280
+ return n === 0 ? a : scale(a, 1 / n);
281
+ }
282
+ function getPointWorld(id, scene) {
283
+ const obj = scene.get(id);
284
+ if (!obj || obj.kind !== "point") {
285
+ throw new Error(`constraintMath: point ${id} not found`);
286
+ }
287
+ return constraintToWorld(obj.constraint, scene);
288
+ }
289
+ function getPlaneBasis(planeObj, scene) {
290
+ const p1 = getPointWorld(planeObj.p1, scene);
291
+ const p2 = getPointWorld(planeObj.p2, scene);
292
+ const p3 = getPointWorld(planeObj.p3, scene);
293
+ const basis1 = sub(p2, p1);
294
+ const tmp = sub(p3, p1);
295
+ const normal = normalize(cross(basis1, tmp));
296
+ const basis2 = cross(normal, basis1);
297
+ return { origin: p1, basis1, basis2, normal };
298
+ }
299
+ function constraintToWorld(c, scene) {
300
+ switch (c.kind) {
301
+ case "free":
302
+ return [c.x, c.y, c.z];
303
+ case "onGround":
304
+ return [c.x, c.y, 0];
305
+ case "onAxis": {
306
+ if (c.axis === "x") return [c.t, 0, 0];
307
+ if (c.axis === "y") return [0, c.t, 0];
308
+ return [0, 0, c.t];
193
309
  }
194
- case "plane": {
195
- const p = resolvePoint(ctx, hit);
196
- ctx.pendingPoints.push(p);
197
- if (ctx.pendingPoints.length === 3) {
198
- finishLineLike(ctx, "plane3d", ctx.pendingPoints);
199
- ctx.pendingPoints = [];
200
- }
201
- ctx.notify();
202
- return;
310
+ case "onPlane": {
311
+ const plane = scene.get(c.planeId);
312
+ if (!plane || plane.kind !== "plane") throw new Error("onPlane: plane missing");
313
+ const { origin, basis1, basis2 } = getPlaneBasis(plane, scene);
314
+ return add(add(origin, scale(basis1, c.u)), scale(basis2, c.v));
203
315
  }
204
- case "triangle": {
205
- const p = resolvePoint(ctx, hit);
206
- ctx.pendingPoints.push(p);
207
- if (ctx.pendingPoints.length === 3) {
208
- finishPolygon(ctx, ctx.pendingPoints);
209
- ctx.pendingPoints = [];
316
+ case "onLine": {
317
+ const line = scene.get(c.lineId);
318
+ if (!line || line.kind !== "line" && line.kind !== "segment" && line.kind !== "ray") {
319
+ throw new Error("onLine: parent missing");
210
320
  }
211
- ctx.notify();
212
- return;
321
+ const p1Id = line.kind === "ray" ? line.origin : line.p1;
322
+ const p2Id = line.kind === "ray" ? line.through : line.p2;
323
+ const p1 = getPointWorld(p1Id, scene);
324
+ const p2 = getPointWorld(p2Id, scene);
325
+ const dir = sub(p2, p1);
326
+ return add(p1, scale(dir, c.t));
213
327
  }
214
- case "polygon": {
215
- if (ctx.pendingPoints.length >= 3 && hit.existingPointId === ctx.pendingPoints[0].id) {
216
- finishPolygon(ctx, ctx.pendingPoints);
217
- ctx.pendingPoints = [];
218
- ctx.notify();
219
- return;
220
- }
221
- const p = resolvePoint(ctx, hit);
222
- ctx.pendingPoints.push(p);
223
- ctx.notify();
224
- return;
328
+ case "onPolygon": {
329
+ const pg = scene.get(c.polygonId);
330
+ if (!pg || pg.kind !== "polygon") throw new Error("onPolygon: parent missing");
331
+ const v = pg.vertices;
332
+ if (v.length < 3) throw new Error("onPolygon: < 3 vertices");
333
+ const p1 = getPointWorld(v[0], scene);
334
+ const p2 = getPointWorld(v[1], scene);
335
+ const p3 = getPointWorld(v[2], scene);
336
+ const basis1 = sub(p2, p1);
337
+ const tmp = sub(p3, p1);
338
+ const normal = normalize(cross(basis1, tmp));
339
+ const basis2 = cross(normal, basis1);
340
+ return add(add(p1, scale(basis1, c.u)), scale(basis2, c.v));
225
341
  }
226
- case "label": {
227
- if (!hit.existingPointId) return;
228
- const text = ctx.promptText("N\u1ED9i dung nh\xE3n");
229
- if (!text) return;
230
- const id = ctx.nextId();
231
- const pointLog = ctx.pushedPointCoords.get(hit.existingPointId);
232
- if (!pointLog) return;
233
- const [x, y, z] = pointLog;
234
- const attrs = {
235
- id,
236
- fontSize: 14,
237
- strokeColor: ctx.isDark ? "#f5f5f5" : "#111111"
238
- };
239
- const ref = ctx.view.create("text3d", [x, y, z, text], attrs);
240
- ctx.objMap.set(id, ref);
241
- ctx.pushLog({
242
- type: "text3d",
243
- parents: [x, y, z, text],
244
- attributes: attrs,
245
- id,
246
- label: text
247
- });
248
- ctx.notify();
249
- return;
342
+ case "onSphere": {
343
+ const sph = scene.get(c.sphereId);
344
+ if (!sph || sph.kind !== "sphere") throw new Error("onSphere: parent missing");
345
+ const center = getPointWorld(sph.center, scene);
346
+ const surface = getPointWorld(sph.surfacePoint, scene);
347
+ const radius = norm(sub(surface, center));
348
+ const x = center[0] + radius * Math.sin(c.phi) * Math.cos(c.theta);
349
+ const y = center[1] + radius * Math.sin(c.phi) * Math.sin(c.theta);
350
+ const z = center[2] + radius * Math.cos(c.phi);
351
+ return [x, y, z];
250
352
  }
251
- // Solids + curved handled in B8, B9
252
- default:
253
- handleSolidStep(ctx, tool, hit);
254
- return;
255
- }
256
- }
257
- function handleSolidStep(ctx, tool, hit) {
258
- switch (tool) {
259
- case "tetrahedron": {
260
- const p = resolvePoint(ctx, hit);
261
- ctx.pendingPoints.push(p);
262
- if (ctx.pendingPoints.length === 4) {
263
- const [a, b, c, d] = ctx.pendingPoints;
264
- finishPolyhedron(ctx, [
265
- [a, b, c],
266
- [a, b, d],
267
- [a, c, d],
268
- [b, c, d]
269
- ]);
270
- ctx.pendingPoints = [];
353
+ }
354
+ }
355
+ var init_constraintMath = __esm({
356
+ "src/stamps/geometry-3d/editor/scene/constraintMath.ts"() {
357
+ }
358
+ });
359
+
360
+ // src/stamps/geometry-3d/editor/scene/geometryChecks.ts
361
+ function getWorld(id, scene) {
362
+ const obj = scene.get(id);
363
+ if (!obj || obj.kind !== "point") return null;
364
+ return constraintToWorld(obj.constraint, scene);
365
+ }
366
+ function areCollinear3(p1Id, p2Id, p3Id, scene) {
367
+ const p1 = getWorld(p1Id, scene);
368
+ const p2 = getWorld(p2Id, scene);
369
+ const p3 = getWorld(p3Id, scene);
370
+ if (!p1 || !p2 || !p3) return true;
371
+ const a = [p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]];
372
+ const b = [p3[0] - p1[0], p3[1] - p1[1], p3[2] - p1[2]];
373
+ const c = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
374
+ return Math.hypot(c[0], c[1], c[2]) < EPS;
375
+ }
376
+ function apexCoplanarWithBase(baseIds, apexId, scene) {
377
+ if (baseIds.length < 3) return false;
378
+ const p1 = getWorld(baseIds[0], scene);
379
+ const p2 = getWorld(baseIds[1], scene);
380
+ const p3 = getWorld(baseIds[2], scene);
381
+ const apex = getWorld(apexId, scene);
382
+ if (!p1 || !p2 || !p3 || !apex) return false;
383
+ const a = [p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]];
384
+ const b = [p3[0] - p1[0], p3[1] - p1[1], p3[2] - p1[2]];
385
+ const n = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
386
+ const d = [apex[0] - p1[0], apex[1] - p1[1], apex[2] - p1[2]];
387
+ const dotND = n[0] * d[0] + n[1] * d[1] + n[2] * d[2];
388
+ return Math.abs(dotND) < EPS;
389
+ }
390
+ var EPS;
391
+ var init_geometryChecks = __esm({
392
+ "src/stamps/geometry-3d/editor/scene/geometryChecks.ts"() {
393
+ init_constraintMath();
394
+ EPS = 1e-6;
395
+ }
396
+ });
397
+
398
+ // src/stamps/geometry-3d/editor/tools/handlers/plane.ts
399
+ function buildPlane(args, scene) {
400
+ if (args.length < 3 || !args[0].hit || !args[1].hit || !args[2].hit) return null;
401
+ const p1 = ensurePoint(args[0].hit, scene);
402
+ const p2 = ensurePoint(args[1].hit, scene);
403
+ const p3 = ensurePoint(args[2].hit, scene);
404
+ if (!p1 || !p2 || !p3) return null;
405
+ if (p1 === p2 || p2 === p3 || p1 === p3) return null;
406
+ if (areCollinear3(p1, p2, p3, scene)) return null;
407
+ return scene.addObject("plane", { p1, p2, p3 });
408
+ }
409
+ var init_plane = __esm({
410
+ "src/stamps/geometry-3d/editor/tools/handlers/plane.ts"() {
411
+ init_ensurePoint();
412
+ init_geometryChecks();
413
+ }
414
+ });
415
+
416
+ // src/stamps/geometry-3d/editor/tools/handlers/pyramid.ts
417
+ function buildPyramid(args, scene) {
418
+ const pointArgs = args.filter((a) => a.step.type === "point");
419
+ const baseArgs = pointArgs.slice(0, -1);
420
+ const apexArg = pointArgs.slice(-1)[0];
421
+ if (baseArgs.length < 3 || !apexArg?.hit) return null;
422
+ const baseIds = baseArgs.map((a) => a.hit ? ensurePoint(a.hit, scene) : null).filter((x) => !!x);
423
+ const apexId = ensurePoint(apexArg.hit, scene);
424
+ if (!apexId || baseIds.length < 3) return null;
425
+ if (apexCoplanarWithBase(baseIds, apexId, scene)) return null;
426
+ const vertices = [...baseIds, apexId];
427
+ const apexIdx = vertices.length - 1;
428
+ const faces = [baseIds.map((_, i) => i)];
429
+ for (let i = 0; i < baseIds.length; i++) {
430
+ faces.push([i, (i + 1) % baseIds.length, apexIdx]);
431
+ }
432
+ return scene.addObject("polyhedron", { flavor: "pyramid", vertices, faces });
433
+ }
434
+ var init_pyramid = __esm({
435
+ "src/stamps/geometry-3d/editor/tools/handlers/pyramid.ts"() {
436
+ init_ensurePoint();
437
+ init_geometryChecks();
438
+ }
439
+ });
440
+
441
+ // src/stamps/geometry-3d/editor/tools/handlers/prism.ts
442
+ function buildPrism(args, scene) {
443
+ const baseArgs = args.filter((a) => a.step.type === "point");
444
+ const numberArg = args.find((a) => a.step.type === "number");
445
+ if (baseArgs.length < 3 || !numberArg || typeof numberArg.value !== "number") return null;
446
+ const height = numberArg.value;
447
+ if (height <= 0) return null;
448
+ const baseIds = baseArgs.map((a) => a.hit ? ensurePoint(a.hit, scene) : null).filter((x) => !!x);
449
+ if (baseIds.length < 3) return null;
450
+ const topIds = [];
451
+ for (const id of baseIds) {
452
+ const p = scene.get(id);
453
+ if (!p || p.kind !== "point") return null;
454
+ const w = constraintToWorld(p.constraint, scene);
455
+ topIds.push(scene.addPoint({ kind: "free", x: w[0], y: w[1], z: w[2] + height }));
456
+ }
457
+ const n = baseIds.length;
458
+ const vertices = [...baseIds, ...topIds];
459
+ const faces = [
460
+ baseIds.map((_, i) => i),
461
+ topIds.map((_, i) => n + i)
462
+ ];
463
+ for (let i = 0; i < n; i++) {
464
+ faces.push([i, (i + 1) % n, n + (i + 1) % n, n + i]);
465
+ }
466
+ return scene.addObject("polyhedron", { flavor: "prism", vertices, faces });
467
+ }
468
+ var init_prism = __esm({
469
+ "src/stamps/geometry-3d/editor/tools/handlers/prism.ts"() {
470
+ init_ensurePoint();
471
+ init_constraintMath();
472
+ }
473
+ });
474
+
475
+ // src/stamps/geometry-3d/editor/tools/handlers/tetrahedron.ts
476
+ function buildTetrahedron(args, scene) {
477
+ if (args.length < 2 || !args[0].hit || !args[1].hit) return null;
478
+ const p1Id = ensurePoint(args[0].hit, scene);
479
+ const p2Id = ensurePoint(args[1].hit, scene);
480
+ if (!p1Id || !p2Id || p1Id === p2Id) return null;
481
+ const p1Obj = scene.get(p1Id);
482
+ const p2Obj = scene.get(p2Id);
483
+ if (!p1Obj || p1Obj.kind !== "point" || !p2Obj || p2Obj.kind !== "point") return null;
484
+ const p1 = constraintToWorld(p1Obj.constraint, scene);
485
+ const p2 = constraintToWorld(p2Obj.constraint, scene);
486
+ const z0 = Math.min(p1[2], p2[2]);
487
+ const baseA = [p1[0], p1[1], z0];
488
+ const baseB = [p2[0], p2[1], z0];
489
+ const dx = baseB[0] - baseA[0];
490
+ const dy = baseB[1] - baseA[1];
491
+ const edge = Math.hypot(dx, dy);
492
+ if (edge < 1e-9) return null;
493
+ const mid = [(baseA[0] + baseB[0]) / 2, (baseA[1] + baseB[1]) / 2, z0];
494
+ const perpX = -dy;
495
+ const perpY = dx;
496
+ const perpLen = Math.hypot(perpX, perpY);
497
+ const height = edge * Math.sqrt(3) / 2;
498
+ const baseC = [mid[0] + perpX / perpLen * height, mid[1] + perpY / perpLen * height, z0];
499
+ const centroid = [
500
+ (baseA[0] + baseB[0] + baseC[0]) / 3,
501
+ (baseA[1] + baseB[1] + baseC[1]) / 3,
502
+ z0
503
+ ];
504
+ const apexHeight = edge * Math.sqrt(2 / 3);
505
+ const apex = [centroid[0], centroid[1], z0 + apexHeight];
506
+ const cId = scene.addPoint({ kind: "free", x: baseC[0], y: baseC[1], z: baseC[2] });
507
+ const apexId = scene.addPoint({ kind: "free", x: apex[0], y: apex[1], z: apex[2] });
508
+ const vertices = [p1Id, p2Id, cId, apexId];
509
+ const faces = [
510
+ [0, 1, 2],
511
+ // base
512
+ [0, 1, 3],
513
+ // face p1-p2-apex
514
+ [1, 2, 3],
515
+ // face p2-c-apex
516
+ [2, 0, 3]
517
+ // face c-p1-apex
518
+ ];
519
+ return scene.addObject("polyhedron", { flavor: "tetrahedron", vertices, faces });
520
+ }
521
+ var init_tetrahedron = __esm({
522
+ "src/stamps/geometry-3d/editor/tools/handlers/tetrahedron.ts"() {
523
+ init_ensurePoint();
524
+ init_constraintMath();
525
+ }
526
+ });
527
+
528
+ // src/stamps/geometry-3d/editor/tools/handlers/cube.ts
529
+ function buildCube(args, scene) {
530
+ if (args.length < 2 || !args[0].hit || !args[1].hit) return null;
531
+ const p1Id = ensurePoint(args[0].hit, scene);
532
+ const p2Id = ensurePoint(args[1].hit, scene);
533
+ if (!p1Id || !p2Id || p1Id === p2Id) return null;
534
+ const p1Obj = scene.get(p1Id);
535
+ const p2Obj = scene.get(p2Id);
536
+ if (!p1Obj || p1Obj.kind !== "point" || !p2Obj || p2Obj.kind !== "point") return null;
537
+ const p1 = constraintToWorld(p1Obj.constraint, scene);
538
+ const p2 = constraintToWorld(p2Obj.constraint, scene);
539
+ if (Math.abs(p1[2]) > 1e-6 || Math.abs(p2[2]) > 1e-6) return null;
540
+ const dx = p2[0] - p1[0];
541
+ const dy = p2[1] - p1[1];
542
+ const edge = Math.hypot(dx, dy);
543
+ if (edge < 1e-9) return null;
544
+ const perpX = -dy;
545
+ const perpY = dx;
546
+ const p3 = [p2[0] + perpX, p2[1] + perpY, 0];
547
+ const p4 = [p1[0] + perpX, p1[1] + perpY, 0];
548
+ const t1 = [p1[0], p1[1], edge];
549
+ const t2 = [p2[0], p2[1], edge];
550
+ const t3 = [p3[0], p3[1], edge];
551
+ const t4 = [p4[0], p4[1], edge];
552
+ const p3Id = scene.addPoint({ kind: "onGround", x: p3[0], y: p3[1] });
553
+ const p4Id = scene.addPoint({ kind: "onGround", x: p4[0], y: p4[1] });
554
+ const t1Id = scene.addPoint({ kind: "free", x: t1[0], y: t1[1], z: t1[2] });
555
+ const t2Id = scene.addPoint({ kind: "free", x: t2[0], y: t2[1], z: t2[2] });
556
+ const t3Id = scene.addPoint({ kind: "free", x: t3[0], y: t3[1], z: t3[2] });
557
+ const t4Id = scene.addPoint({ kind: "free", x: t4[0], y: t4[1], z: t4[2] });
558
+ const vertices = [p1Id, p2Id, p3Id, p4Id, t1Id, t2Id, t3Id, t4Id];
559
+ const faces = [
560
+ [0, 1, 2, 3],
561
+ // bottom
562
+ [4, 5, 6, 7],
563
+ // top
564
+ [0, 1, 5, 4],
565
+ // front
566
+ [1, 2, 6, 5],
567
+ // right
568
+ [2, 3, 7, 6],
569
+ // back
570
+ [3, 0, 4, 7]
571
+ // left
572
+ ];
573
+ return scene.addObject("polyhedron", { flavor: "cube", vertices, faces });
574
+ }
575
+ var init_cube = __esm({
576
+ "src/stamps/geometry-3d/editor/tools/handlers/cube.ts"() {
577
+ init_ensurePoint();
578
+ init_constraintMath();
579
+ }
580
+ });
581
+
582
+ // src/stamps/geometry-3d/editor/tools/handlers/sphere.ts
583
+ function buildSphere(args, scene) {
584
+ if (args.length < 2 || !args[0].hit || !args[1].hit) return null;
585
+ const center = ensurePoint(args[0].hit, scene);
586
+ const surface = ensurePoint(args[1].hit, scene);
587
+ if (!center || !surface || center === surface) return null;
588
+ return scene.addObject("sphere", { center, surfacePoint: surface });
589
+ }
590
+ var init_sphere = __esm({
591
+ "src/stamps/geometry-3d/editor/tools/handlers/sphere.ts"() {
592
+ init_ensurePoint();
593
+ }
594
+ });
595
+
596
+ // src/stamps/geometry-3d/editor/tools/handlers/cylinder.ts
597
+ function buildCylinder(args, scene) {
598
+ const points = args.filter((a) => a.step.type === "point");
599
+ const numberArg = args.find((a) => a.step.type === "number");
600
+ if (points.length < 2 || !points[0].hit || !points[1].hit || !numberArg || typeof numberArg.value !== "number") return null;
601
+ const radius = numberArg.value;
602
+ if (radius <= 0) return null;
603
+ const baseCenter = ensurePoint(points[0].hit, scene);
604
+ const topCenter = ensurePoint(points[1].hit, scene);
605
+ if (!baseCenter || !topCenter || baseCenter === topCenter) return null;
606
+ return scene.addObject("cylinder", { baseCenter, topCenter, radius });
607
+ }
608
+ var init_cylinder = __esm({
609
+ "src/stamps/geometry-3d/editor/tools/handlers/cylinder.ts"() {
610
+ init_ensurePoint();
611
+ }
612
+ });
613
+
614
+ // src/stamps/geometry-3d/editor/tools/handlers/cone.ts
615
+ function buildCone(args, scene) {
616
+ const points = args.filter((a) => a.step.type === "point");
617
+ const numberArg = args.find((a) => a.step.type === "number");
618
+ if (points.length < 2 || !points[0].hit || !points[1].hit || !numberArg || typeof numberArg.value !== "number") return null;
619
+ const radius = numberArg.value;
620
+ if (radius <= 0) return null;
621
+ const baseCenter = ensurePoint(points[0].hit, scene);
622
+ const apex = ensurePoint(points[1].hit, scene);
623
+ if (!baseCenter || !apex || baseCenter === apex) return null;
624
+ return scene.addObject("cone", { baseCenter, apex, radius });
625
+ }
626
+ var init_cone = __esm({
627
+ "src/stamps/geometry-3d/editor/tools/handlers/cone.ts"() {
628
+ init_ensurePoint();
629
+ }
630
+ });
631
+
632
+ // src/stamps/geometry-3d/editor/tools/spec.ts
633
+ var stubBuild, ALL_SURFACES, OBJECT_ONLY, NO_SURFACE, TOOLS;
634
+ var init_spec = __esm({
635
+ "src/stamps/geometry-3d/editor/tools/spec.ts"() {
636
+ init_point();
637
+ init_segment();
638
+ init_polygon();
639
+ init_plane();
640
+ init_pyramid();
641
+ init_prism();
642
+ init_tetrahedron();
643
+ init_cube();
644
+ init_sphere();
645
+ init_cylinder();
646
+ init_cone();
647
+ stubBuild = () => null;
648
+ ALL_SURFACES = ["ground", "axis", "plane", "line", "polygon", "sphere"];
649
+ OBJECT_ONLY = ["plane", "line", "polygon", "sphere"];
650
+ NO_SURFACE = ["ground", "axis", "plane"];
651
+ TOOLS = [
652
+ {
653
+ key: "move",
654
+ label: "Di chuy\u1EC3n",
655
+ hintIdle: "K\xE9o \u0111i\u1EC3m ho\u1EB7c xoay khung",
656
+ steps: [],
657
+ build: stubBuild
658
+ },
659
+ {
660
+ key: "point",
661
+ label: "\u0110i\u1EC3m",
662
+ hintIdle: "Click tr\xEAn m\u1EB7t ph\u1EB3ng Oxy ho\u1EB7c tr\xEAn tr\u1EE5c \u0111\u1EC3 \u0111\u1EB7t \u0111i\u1EC3m",
663
+ steps: [
664
+ {
665
+ type: "point",
666
+ allowExisting: false,
667
+ // GeoGebra-style: a new point must lie on the XY ground plane or on
668
+ // one of the coordinate axes (Oz lets you place points off the plane).
669
+ allowNewOn: ["ground", "axis"],
670
+ hint: "Click tr\xEAn m\u1EB7t ph\u1EB3ng Oxy ho\u1EB7c tr\u1EE5c Ox/Oy/Oz"
671
+ }
672
+ ],
673
+ build: buildPoint,
674
+ repeatAfterBuild: true
675
+ },
676
+ {
677
+ key: "pointOnObject",
678
+ label: "\u0110i\u1EC3m tr\xEAn \u0111\u1ED1i t\u01B0\u1EE3ng",
679
+ hintIdle: "Ch\u1ECDn m\u1ED9t \u0111\u1ED1i t\u01B0\u1EE3ng \u0111\u1EC3 \u0111\u1EB7t \u0111i\u1EC3m",
680
+ steps: [{ type: "point", allowExisting: false, allowNewOn: OBJECT_ONLY, hint: "Click l\xEAn m\u1EB7t / \u0111\u01B0\u1EDDng \u0111\u1EC3 \u0111\u1EB7t \u0111i\u1EC3m" }],
681
+ build: buildPointOnObject
682
+ },
683
+ {
684
+ key: "segment",
685
+ label: "\u0110o\u1EA1n th\u1EB3ng",
686
+ hintIdle: "Ch\u1ECDn 2 \u0111i\u1EC3m \u0111\u1EC3 v\u1EBD \u0111o\u1EA1n th\u1EB3ng",
687
+ steps: [
688
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m th\u1EE9 nh\u1EA5t" },
689
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m th\u1EE9 hai" }
690
+ ],
691
+ build: buildSegment
692
+ },
693
+ {
694
+ key: "line",
695
+ label: "\u0110\u01B0\u1EDDng th\u1EB3ng",
696
+ hintIdle: "Ch\u1ECDn 2 \u0111i\u1EC3m \u0111\u1EC3 v\u1EBD \u0111\u01B0\u1EDDng th\u1EB3ng",
697
+ steps: [
698
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m th\u1EE9 nh\u1EA5t" },
699
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m th\u1EE9 hai" }
700
+ ],
701
+ build: buildLine
702
+ },
703
+ {
704
+ key: "ray",
705
+ label: "Tia",
706
+ hintIdle: "Ch\u1ECDn \u0111i\u1EC3m g\u1ED1c r\u1ED3i \u0111i\u1EC3m tr\xEAn tia",
707
+ steps: [
708
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m g\u1ED1c" },
709
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m tr\xEAn tia" }
710
+ ],
711
+ build: buildRay
712
+ },
713
+ {
714
+ key: "vector",
715
+ label: "Vector",
716
+ hintIdle: "Ch\u1ECDn 2 \u0111i\u1EC3m \u0111\u1EC3 v\u1EBD vector",
717
+ steps: [
718
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m \u0111\u1EA7u" },
719
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m cu\u1ED1i" }
720
+ ],
721
+ build: buildVector
722
+ },
723
+ {
724
+ key: "polygon",
725
+ label: "\u0110a gi\xE1c",
726
+ hintIdle: "Ch\u1ECDn c\xE1c \u0111\u1EC9nh; click \u0111i\u1EC3m \u0111\u1EA7u \u0111\u1EC3 \u0111\xF3ng",
727
+ steps: [
728
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111\u1EC9nh th\u1EE9 1" },
729
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111\u1EC9nh th\u1EE9 2" },
730
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111\u1EC9nh th\u1EE9 3" },
731
+ { type: "closingPoint", hint: "Click \u0111i\u1EC3m \u0111\u1EA7u \u0111\u1EC3 \u0111\xF3ng (ho\u1EB7c ch\u1ECDn th\xEAm \u0111\u1EC9nh)" }
732
+ ],
733
+ build: buildPolygon
734
+ },
735
+ {
736
+ key: "plane",
737
+ label: "M\u1EB7t ph\u1EB3ng (3 \u0111i\u1EC3m)",
738
+ hintIdle: "Ch\u1ECDn 3 \u0111i\u1EC3m \u0111\u1EC3 x\xE1c \u0111\u1ECBnh m\u1EB7t ph\u1EB3ng",
739
+ steps: [
740
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m th\u1EE9 1 c\u1EE7a m\u1EB7t ph\u1EB3ng" },
741
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m th\u1EE9 2" },
742
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m th\u1EE9 3" }
743
+ ],
744
+ build: buildPlane
745
+ },
746
+ {
747
+ key: "pyramid",
748
+ label: "H\xECnh ch\xF3p",
749
+ hintIdle: "Ch\u1ECDn \u0111\xE1y \u0111a gi\xE1c r\u1ED3i ch\u1ECDn \u0111\u1EC9nh ch\xF3p",
750
+ steps: [
751
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111\u1EC9nh \u0111\xE1y 1" },
752
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111\u1EC9nh \u0111\xE1y 2" },
753
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111\u1EC9nh \u0111\xE1y 3" },
754
+ { type: "closingPoint", hint: "Click \u0111\u1EC9nh \u0111\xE1y \u0111\u1EA7u ti\xEAn \u0111\u1EC3 \u0111\xF3ng (ho\u1EB7c ch\u1ECDn th\xEAm \u0111\u1EC9nh)" },
755
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111\u1EC9nh ch\xF3p" }
756
+ ],
757
+ build: buildPyramid
758
+ },
759
+ {
760
+ key: "prism",
761
+ label: "L\u0103ng tr\u1EE5",
762
+ hintIdle: "Ch\u1ECDn \u0111\xE1y \u0111a gi\xE1c r\u1ED3i nh\u1EADp chi\u1EC1u cao",
763
+ steps: [
764
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111\u1EC9nh \u0111\xE1y 1" },
765
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111\u1EC9nh \u0111\xE1y 2" },
766
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111\u1EC9nh \u0111\xE1y 3" },
767
+ { type: "closingPoint", hint: "Click \u0111\u1EC9nh \u0111\u1EA7u \u0111\u1EC3 \u0111\xF3ng \u0111\xE1y" },
768
+ { type: "number", prompt: "Chi\u1EC1u cao (theo tr\u1EE5c z)", min: 1e-4 }
769
+ ],
770
+ build: buildPrism
771
+ },
772
+ {
773
+ key: "tetrahedron",
774
+ label: "T\u1EE9 di\u1EC7n \u0111\u1EC1u",
775
+ hintIdle: "Ch\u1ECDn 2 \u0111i\u1EC3m x\xE1c \u0111\u1ECBnh c\u1EA1nh c\u1EE7a t\u1EE9 di\u1EC7n",
776
+ steps: [
777
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111i\u1EC3m 1" },
778
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111i\u1EC3m 2" }
779
+ ],
780
+ build: buildTetrahedron
781
+ },
782
+ {
783
+ key: "cube",
784
+ label: "L\u1EADp ph\u01B0\u01A1ng",
785
+ hintIdle: "Ch\u1ECDn 2 \u0111i\u1EC3m tr\xEAn n\u1EC1n x\xE1c \u0111\u1ECBnh c\u1EA1nh",
786
+ steps: [
787
+ { type: "point", allowExisting: true, allowNewOn: ["ground"], hint: "Ch\u1ECDn \u0111i\u1EC3m 1 (tr\xEAn n\u1EC1n)" },
788
+ { type: "point", allowExisting: true, allowNewOn: ["ground"], hint: "Ch\u1ECDn \u0111i\u1EC3m 2 (tr\xEAn n\u1EC1n)" }
789
+ ],
790
+ build: buildCube
791
+ },
792
+ {
793
+ key: "sphere",
794
+ label: "M\u1EB7t c\u1EA7u",
795
+ hintIdle: "Ch\u1ECDn t\xE2m r\u1ED3i \u0111i\u1EC3m tr\xEAn m\u1EB7t c\u1EA7u",
796
+ steps: [
797
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn t\xE2m m\u1EB7t c\u1EA7u" },
798
+ { type: "point", allowExisting: true, allowNewOn: ALL_SURFACES, hint: "Ch\u1ECDn \u0111i\u1EC3m tr\xEAn m\u1EB7t c\u1EA7u" }
799
+ ],
800
+ build: buildSphere
801
+ },
802
+ {
803
+ key: "cylinder",
804
+ label: "H\xECnh tr\u1EE5",
805
+ hintIdle: "Ch\u1ECDn t\xE2m \u0111\xE1y, t\xE2m tr\xEAn, r\u1ED3i nh\u1EADp b\xE1n k\xEDnh",
806
+ steps: [
807
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn t\xE2m \u0111\xE1y" },
808
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn t\xE2m tr\xEAn" },
809
+ { type: "number", prompt: "B\xE1n k\xEDnh", min: 1e-4 }
810
+ ],
811
+ build: buildCylinder
812
+ },
813
+ {
814
+ key: "cone",
815
+ label: "H\xECnh n\xF3n",
816
+ hintIdle: "Ch\u1ECDn t\xE2m \u0111\xE1y, \u0111\u1EC9nh, r\u1ED3i nh\u1EADp b\xE1n k\xEDnh",
817
+ steps: [
818
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn t\xE2m \u0111\xE1y" },
819
+ { type: "point", allowExisting: true, allowNewOn: NO_SURFACE, hint: "Ch\u1ECDn \u0111\u1EC9nh" },
820
+ { type: "number", prompt: "B\xE1n k\xEDnh", min: 1e-4 }
821
+ ],
822
+ build: buildCone
271
823
  }
272
- ctx.notify();
273
- return;
274
- }
275
- case "parallelepiped": {
276
- const origin = resolvePoint(ctx, hit);
277
- const v1 = ctx.promptCoords("Vector c\u1EA1nh 1 (dx, dy, dz)");
278
- const v2 = ctx.promptCoords("Vector c\u1EA1nh 2 (dx, dy, dz)");
279
- const v3 = ctx.promptCoords("Vector c\u1EA1nh 3 (dx, dy, dz)");
280
- if (!v1 || !v2 || !v3) return;
281
- const [ox, oy, oz] = origin.coords;
282
- const c1 = createPoint3D(ctx, ox + v1.x, oy + v1.y, oz + v1.z);
283
- const c2 = createPoint3D(ctx, ox + v2.x, oy + v2.y, oz + v2.z);
284
- const c3 = createPoint3D(ctx, ox + v3.x, oy + v3.y, oz + v3.z);
285
- const c12 = createPoint3D(
286
- ctx,
287
- ox + v1.x + v2.x,
288
- oy + v1.y + v2.y,
289
- oz + v1.z + v2.z
290
- );
291
- const c13 = createPoint3D(
292
- ctx,
293
- ox + v1.x + v3.x,
294
- oy + v1.y + v3.y,
295
- oz + v1.z + v3.z
296
- );
297
- const c23 = createPoint3D(
298
- ctx,
299
- ox + v2.x + v3.x,
300
- oy + v2.y + v3.y,
301
- oz + v2.z + v3.z
302
- );
303
- const c123 = createPoint3D(
304
- ctx,
305
- ox + v1.x + v2.x + v3.x,
306
- oy + v1.y + v2.y + v3.y,
307
- oz + v1.z + v2.z + v3.z
308
- );
309
- finishPolyhedron(ctx, [
310
- [origin, c1, c12, c2],
311
- [origin, c1, c13, c3],
312
- [origin, c2, c23, c3],
313
- [c123, c12, c1, c13],
314
- [c123, c12, c2, c23],
315
- [c123, c13, c3, c23]
316
- ]);
317
- ctx.pendingPoints = [];
318
- ctx.notify();
319
- return;
320
- }
321
- case "prism": {
322
- if (ctx.pendingPoints.length >= 3 && hit.existingPointId === ctx.pendingPoints[0].id) {
323
- const base = ctx.pendingPoints;
324
- const height = ctx.promptNumber("Chi\u1EC1u cao (theo tr\u1EE5c z)");
325
- if (!height) return;
326
- const top = base.map(
327
- (bp) => createPoint3D(ctx, bp.coords[0], bp.coords[1], bp.coords[2] + height)
328
- );
329
- const faces = [base, top];
330
- for (let i = 0; i < base.length; i++) {
331
- const next = (i + 1) % base.length;
332
- faces.push([base[i], base[next], top[next], top[i]]);
333
- }
334
- finishPolyhedron(ctx, faces);
335
- ctx.pendingPoints = [];
336
- ctx.notify();
337
- return;
824
+ ];
825
+ }
826
+ });
827
+
828
+ // src/stamps/geometry-3d/editor/tools/controller.ts
829
+ function stepHint(step) {
830
+ return step.type === "number" ? step.prompt : step.hint;
831
+ }
832
+ var ToolController;
833
+ var init_controller = __esm({
834
+ "src/stamps/geometry-3d/editor/tools/controller.ts"() {
835
+ init_spec();
836
+ ToolController = class {
837
+ constructor(scene) {
838
+ this.scene = scene;
839
+ this.state = { tool: null, stepIndex: 0, collected: [], hint: "" };
840
+ this.listeners = /* @__PURE__ */ new Set();
841
+ this.selectTool("move");
338
842
  }
339
- const p = resolvePoint(ctx, hit);
340
- ctx.pendingPoints.push(p);
341
- ctx.notify();
342
- return;
343
- }
344
- case "pyramid": {
345
- const baseDone = ctx.pendingFlags.pyramidBaseDone === true;
346
- if (!baseDone && ctx.pendingPoints.length >= 3 && hit.existingPointId === ctx.pendingPoints[0].id) {
347
- ctx.pendingFlags.pyramidBaseDone = true;
348
- ctx.notify();
349
- return;
843
+ getState() {
844
+ return this.state;
350
845
  }
351
- if (baseDone) {
352
- const base = ctx.pendingPoints;
353
- const apex = createPoint3D(ctx, hit.x3, hit.y3, hit.z3);
354
- const faces = [base];
355
- for (let i = 0; i < base.length; i++) {
356
- const next = (i + 1) % base.length;
357
- faces.push([base[i], base[next], apex]);
358
- }
359
- finishPolyhedron(ctx, faces);
360
- ctx.pendingPoints = [];
361
- ctx.pendingFlags.pyramidBaseDone = false;
362
- ctx.notify();
363
- return;
846
+ on(cb) {
847
+ this.listeners.add(cb);
848
+ return () => {
849
+ this.listeners.delete(cb);
850
+ };
364
851
  }
365
- const p = resolvePoint(ctx, hit);
366
- ctx.pendingPoints.push(p);
367
- ctx.notify();
368
- return;
369
- }
370
- // Curved → B9
371
- default:
372
- handleCurvedStep(ctx, tool, hit);
373
- return;
374
- }
375
- }
376
- function finishPolyhedron(ctx, faces) {
377
- const faceColor = ctx.isDark ? "rgba(150, 180, 220, 0.35)" : "rgba(60, 120, 200, 0.25)";
378
- const edgeColor = ctx.isDark ? "#9ecbff" : "#0066cc";
379
- for (const face of faces) {
380
- finishPolygon(ctx, face, {
381
- fillColor: faceColor,
382
- fillOpacity: 1,
383
- strokeColor: edgeColor,
384
- strokeWidth: 1.5,
385
- visible: true
386
- });
387
- }
388
- }
389
- function handleCurvedStep(ctx, tool, hit) {
390
- switch (tool) {
391
- case "sphere": {
392
- const radius = ctx.promptNumber("B\xE1n k\xEDnh m\u1EB7t c\u1EA7u");
393
- if (radius == null) return;
394
- const center = resolvePoint(ctx, hit);
395
- const id = ctx.nextId();
396
- const ref = ctx.view.create("sphere3d", [center.ref, radius], { id });
397
- ctx.objMap.set(id, ref);
398
- ctx.pushLog({
399
- type: "sphere3d",
400
- parents: [refByPlaceholder(center.id), radius],
401
- attributes: { id },
402
- id
403
- });
404
- ctx.notify();
405
- return;
406
- }
407
- case "cone": {
408
- const baseDone = ctx.pendingFlags.coneBaseDone === true;
409
- if (!baseDone) {
410
- const radius2 = ctx.promptNumber("B\xE1n k\xEDnh \u0111\xE1y");
411
- if (radius2 == null) return;
412
- const center2 = resolvePoint(ctx, hit);
413
- ctx.pendingFlags.coneCenter = center2;
414
- ctx.pendingFlags.coneRadius = radius2;
415
- ctx.pendingFlags.coneBaseDone = true;
416
- ctx.notify();
417
- return;
852
+ selectTool(key) {
853
+ const tool = TOOLS.find((t) => t.key === key) ?? TOOLS.find((t) => t.key === "move");
854
+ const firstStep = tool.steps[0];
855
+ this.state = {
856
+ tool,
857
+ stepIndex: 0,
858
+ collected: [],
859
+ hint: firstStep ? stepHint(firstStep) : tool.hintIdle
860
+ };
861
+ this.notify();
418
862
  }
419
- const center = ctx.pendingFlags.coneCenter;
420
- const radius = ctx.pendingFlags.coneRadius;
421
- const apex = createPoint3D(ctx, hit.x3, hit.y3, hit.z3);
422
- const [cx, cy, cz] = center.coords;
423
- const basePoints = [];
424
- for (let i = 0; i < CURVED_SEGMENTS; i++) {
425
- const theta = i / CURVED_SEGMENTS * Math.PI * 2;
426
- basePoints.push(
427
- createPoint3D(
428
- ctx,
429
- cx + radius * Math.cos(theta),
430
- cy + radius * Math.sin(theta),
431
- cz
432
- )
433
- );
863
+ cancel() {
864
+ this.selectTool("move");
434
865
  }
435
- const faces = [basePoints];
436
- for (let i = 0; i < CURVED_SEGMENTS; i++) {
437
- faces.push([basePoints[i], basePoints[(i + 1) % CURVED_SEGMENTS], apex]);
866
+ consumeHit(hit) {
867
+ const tool = this.state.tool;
868
+ if (!tool) return false;
869
+ const step = tool.steps[this.state.stepIndex];
870
+ if (!step) return false;
871
+ if (step.type === "closingPoint") {
872
+ if (hit.kind === "empty") return false;
873
+ if (hit.kind === "existingPoint") {
874
+ this.state.collected.push({ step, hit });
875
+ this.state.stepIndex++;
876
+ this.advance();
877
+ return true;
878
+ }
879
+ const prevStep = tool.steps[this.state.stepIndex - 1];
880
+ if (!prevStep || prevStep.type !== "point") return false;
881
+ if (!this.hitMatchesStep(hit, prevStep)) return false;
882
+ this.state.collected.push({ step: prevStep, hit });
883
+ this.notify();
884
+ return true;
885
+ }
886
+ if (!this.hitMatchesStep(hit, step)) return false;
887
+ this.state.collected.push({ step, hit });
888
+ this.state.stepIndex++;
889
+ this.advance();
890
+ return true;
438
891
  }
439
- finishPolyhedron(ctx, faces);
440
- ctx.pendingFlags.coneBaseDone = false;
441
- ctx.pendingFlags.coneCenter = void 0;
442
- ctx.pendingFlags.coneRadius = void 0;
443
- ctx.notify();
444
- return;
445
- }
446
- case "cylinder": {
447
- const radius = ctx.promptNumber("B\xE1n k\xEDnh \u0111\xE1y");
448
- if (radius == null) return;
449
- const height = ctx.promptNumber("Chi\u1EC1u cao (theo tr\u1EE5c z)");
450
- if (height == null) return;
451
- const center = resolvePoint(ctx, hit);
452
- const [cx, cy, cz] = center.coords;
453
- const basePoints = [];
454
- const topPoints = [];
455
- for (let i = 0; i < CURVED_SEGMENTS; i++) {
456
- const theta = i / CURVED_SEGMENTS * Math.PI * 2;
457
- basePoints.push(
458
- createPoint3D(
459
- ctx,
460
- cx + radius * Math.cos(theta),
461
- cy + radius * Math.sin(theta),
462
- cz
463
- )
464
- );
465
- topPoints.push(
466
- createPoint3D(
467
- ctx,
468
- cx + radius * Math.cos(theta),
469
- cy + radius * Math.sin(theta),
470
- cz + height
471
- )
472
- );
892
+ consumeNumber(value) {
893
+ const tool = this.state.tool;
894
+ if (!tool) return false;
895
+ const step = tool.steps[this.state.stepIndex];
896
+ if (!step || step.type !== "number") return false;
897
+ if (step.min != null && value < step.min) return false;
898
+ if (step.max != null && value > step.max) return false;
899
+ this.state.collected.push({ step, value });
900
+ this.state.stepIndex++;
901
+ this.advance();
902
+ return true;
473
903
  }
474
- const faces = [basePoints, topPoints];
475
- for (let i = 0; i < CURVED_SEGMENTS; i++) {
476
- const next = (i + 1) % CURVED_SEGMENTS;
477
- faces.push([basePoints[i], basePoints[next], topPoints[next], topPoints[i]]);
904
+ hitMatchesStep(hit, step) {
905
+ if (step.type !== "point" && step.type !== "closingPoint") return false;
906
+ if (hit.kind === "empty") return false;
907
+ if (step.type === "closingPoint") return hit.kind === "existingPoint";
908
+ if (hit.kind === "existingPoint") return step.allowExisting;
909
+ const surfaceMap = {
910
+ onGround: "ground",
911
+ onAxis: "axis",
912
+ onPlane: "plane",
913
+ onLine: "line",
914
+ onPolygon: "polygon",
915
+ onSphere: "sphere"
916
+ };
917
+ const k = surfaceMap[hit.kind];
918
+ return k != null && step.type === "point" && step.allowNewOn.includes(k);
478
919
  }
479
- finishPolyhedron(ctx, faces);
480
- ctx.notify();
481
- return;
482
- }
483
- // 'solidofrevolution' removed in 0.6.1 — `solidofrevolution3d` is not a valid
484
- // JSXGraph 1.12.2 element. See Bug #8.
485
- default:
486
- return;
920
+ advance() {
921
+ const tool = this.state.tool;
922
+ if (this.state.stepIndex >= tool.steps.length) {
923
+ tool.build(this.state.collected, this.scene);
924
+ if (tool.repeatAfterBuild) {
925
+ this.state.stepIndex = 0;
926
+ this.state.collected = [];
927
+ this.state.hint = stepHint(tool.steps[0]);
928
+ this.notify();
929
+ } else {
930
+ this.selectTool("move");
931
+ }
932
+ return;
933
+ }
934
+ this.state.hint = stepHint(tool.steps[this.state.stepIndex]);
935
+ this.notify();
936
+ }
937
+ notify() {
938
+ for (const cb of this.listeners) cb(this.state);
939
+ }
940
+ };
941
+ }
942
+ });
943
+
944
+ // src/stamps/geometry-3d/editor/renderer/faceted.ts
945
+ function cylinderFaces(center, top, radius) {
946
+ const baseRing = [];
947
+ const topRing = [];
948
+ for (let i = 0; i < CURVED_SEGMENTS; i++) {
949
+ const theta = i / CURVED_SEGMENTS * Math.PI * 2;
950
+ const dx = radius * Math.cos(theta);
951
+ const dy = radius * Math.sin(theta);
952
+ baseRing.push([center[0] + dx, center[1] + dy, center[2]]);
953
+ topRing.push([top[0] + dx, top[1] + dy, top[2]]);
954
+ }
955
+ const vertices = [...baseRing, ...topRing];
956
+ const faces = [];
957
+ faces.push(baseRing.map((_, i) => i));
958
+ faces.push(topRing.map((_, i) => CURVED_SEGMENTS + i));
959
+ for (let i = 0; i < CURVED_SEGMENTS; i++) {
960
+ const next = (i + 1) % CURVED_SEGMENTS;
961
+ faces.push([i, next, CURVED_SEGMENTS + next, CURVED_SEGMENTS + i]);
487
962
  }
963
+ return { vertices, faces };
964
+ }
965
+ function coneFaces(baseCenter, apex, radius) {
966
+ const baseRing = [];
967
+ for (let i = 0; i < CURVED_SEGMENTS; i++) {
968
+ const theta = i / CURVED_SEGMENTS * Math.PI * 2;
969
+ baseRing.push([
970
+ baseCenter[0] + radius * Math.cos(theta),
971
+ baseCenter[1] + radius * Math.sin(theta),
972
+ baseCenter[2]
973
+ ]);
974
+ }
975
+ const apexIdx = baseRing.length;
976
+ const vertices = [...baseRing, apex];
977
+ const faces = [baseRing.map((_, i) => i)];
978
+ for (let i = 0; i < CURVED_SEGMENTS; i++) {
979
+ faces.push([i, (i + 1) % CURVED_SEGMENTS, apexIdx]);
980
+ }
981
+ return { vertices, faces };
488
982
  }
489
983
  var CURVED_SEGMENTS;
490
- var init_handlers = __esm({
491
- "src/stamps/geometry-3d/editor/handlers.ts"() {
984
+ var init_faceted = __esm({
985
+ "src/stamps/geometry-3d/editor/renderer/faceted.ts"() {
492
986
  CURVED_SEGMENTS = 16;
493
987
  }
494
988
  });
495
- var MiniBoard3D;
496
- var init_MiniBoard3D = __esm({
497
- "src/stamps/geometry-3d/editor/MiniBoard3D.tsx"() {
498
- "use client";
499
- init_theme2();
500
- init_handlers();
501
- MiniBoard3D = react.forwardRef(function MiniBoard3D2({ isDark, initialState }, ref) {
502
- const reactId = react.useId();
503
- const containerId = `geom3d_${reactId.replace(/[^a-zA-Z0-9_]/g, "_")}`;
504
- const containerRef = react.useRef(null);
505
- const boardRef = react.useRef(null);
506
- const viewRef = react.useRef(null);
507
- const toolRef = react.useRef("move");
508
- const logRef = react.useRef([]);
509
- const objMapRef = react.useRef(/* @__PURE__ */ new Map());
510
- const subsRef = react.useRef(/* @__PURE__ */ new Set());
511
- const initialBbox3D = react.useRef(
512
- initialState?.view.bbox3D ?? DEFAULT_VIEW3D.bbox3D
513
- );
514
- const ctxRef = react.useRef(null);
515
- const pointerHandlerRef = react.useRef(null);
516
- const [showAxes, setShowAxes] = react.useState(initialState?.showAxes ?? true);
517
- const [showMesh, setShowMesh] = react.useState(initialState?.showMesh ?? false);
518
- const notify = react.useCallback(() => {
519
- for (const cb of subsRef.current) cb();
520
- }, []);
521
- react.useEffect(() => {
522
- const div = containerRef.current;
523
- if (!div) return;
524
- let cancelled = false;
525
- let JXG = null;
526
- let board = null;
527
- void (async () => {
528
- JXG = (await import('jsxgraph')).default;
529
- if (cancelled || !containerRef.current) return;
530
- JXG.Options.text.display = "internal";
531
- board = JXG.JSXGraph.initBoard(div, {
532
- boundingbox: [-6, 6, 6, -6],
533
- axis: false,
534
- showCopyright: false,
535
- showNavigation: false,
536
- renderer: "svg"
537
- });
538
- boardRef.current = board;
539
- const initView = initialState?.view ?? DEFAULT_VIEW3D;
540
- const baseAttrs = VIEW3D_ATTRS(isDark);
541
- const view = board.create(
542
- "view3d",
543
- [
544
- [-5, -5],
545
- [10, 10],
546
- [
547
- [initView.bbox3D[0], initView.bbox3D[3]],
548
- [initView.bbox3D[1], initView.bbox3D[4]],
549
- [initView.bbox3D[2], initView.bbox3D[5]]
550
- ]
551
- ],
552
- {
553
- ...baseAttrs,
554
- az: { ...baseAttrs.az, value: initView.azimuth },
555
- el: { ...baseAttrs.el, value: initView.elevation }
556
- }
557
- );
558
- viewRef.current = view;
559
- let idCounter = 1;
560
- const ctx = createHandlerContext({
561
- view,
562
- pushLog: (e) => {
563
- logRef.current.push(e);
564
- notify();
565
- },
566
- objMap: objMapRef.current,
567
- nextId: () => `obj_${Date.now().toString(36)}_${(idCounter++).toString(36)}`,
568
- isDark,
569
- promptCoords: (label) => {
570
- const raw = window.prompt(`${label}
571
- (\u0111\u1ECBnh d\u1EA1ng "x,y,z")`, "0,0,0");
572
- if (!raw) return null;
573
- const parts = raw.split(",").map((s) => Number(s.trim()));
574
- if (parts.length !== 3 || parts.some((n) => !isFinite(n))) return null;
575
- return { x: parts[0], y: parts[1], z: parts[2] };
576
- },
577
- promptNumber: (label) => {
578
- const raw = window.prompt(label, "1");
579
- if (raw == null) return null;
580
- const n = Number(raw);
581
- return isFinite(n) ? n : null;
582
- },
583
- promptText: (label) => {
584
- const raw = window.prompt(label, "");
585
- return raw == null ? null : raw;
586
- },
587
- notify
588
- });
589
- ctxRef.current = ctx;
590
- function findExistingPointAt(clientX, clientY) {
591
- const containerRect = div.getBoundingClientRect();
592
- const localX = clientX - containerRect.left;
593
- const localY = clientY - containerRect.top;
594
- const PICK = 18;
595
- const svg = div.querySelector("svg");
596
- if (!svg) return void 0;
597
- for (const [id, obj] of objMapRef.current) {
598
- const entry = obj;
599
- if (entry?.elType !== "point3d") continue;
600
- const sc = entry.element2D?.coords?.scrCoords;
601
- if (!sc || sc.length < 3) continue;
602
- const dx = sc[1] - localX;
603
- const dy = sc[2] - localY;
604
- if (dx * dx + dy * dy <= PICK * PICK) return id;
605
- }
606
- return void 0;
607
- }
608
- const handlePointerDown = (e) => {
609
- const tool = toolRef.current;
610
- if (tool === "move") return;
611
- const existingPointId = findExistingPointAt(e.clientX, e.clientY);
612
- let x3 = 0;
613
- let y3 = 0;
614
- const z3 = 0;
615
- try {
616
- const board2d = boardRef.current;
617
- if (board2d?.getUsrCoordsOfMouse) {
618
- const uc = board2d.getUsrCoordsOfMouse(e);
619
- if (Array.isArray(uc) && uc.length >= 2) {
620
- x3 = uc[0];
621
- y3 = uc[1];
622
- }
623
- }
624
- } catch {
625
- }
626
- const hit = { x3, y3, z3, existingPointId };
627
- handleToolStep(ctx, tool, hit);
628
- };
629
- const svgEl = div.querySelector("svg");
630
- const targetEl = svgEl ?? div;
631
- const handlePointerDownEv = (e) => handlePointerDown(e);
632
- targetEl.addEventListener("pointerdown", handlePointerDownEv);
633
- pointerHandlerRef.current = { el: targetEl, fn: handlePointerDownEv };
634
- if (initialState?.elements?.length) {
635
- const map = objMapRef.current;
636
- for (const el of initialState.elements) {
637
- const parents = el.parents.map(
638
- (p2) => typeof p2 === "string" && p2.startsWith("@id:") ? map.get(p2.slice(4)) : p2
639
- );
640
- const obj = view.create(el.type, parents, {
641
- ...el.attributes,
642
- id: el.id,
643
- name: el.label
644
- });
645
- map.set(el.id, obj);
646
- logRef.current.push(el);
647
- }
648
- }
649
- })();
650
- return () => {
651
- cancelled = true;
652
- if (pointerHandlerRef.current) {
653
- pointerHandlerRef.current.el.removeEventListener(
654
- "pointerdown",
655
- pointerHandlerRef.current.fn
656
- );
657
- pointerHandlerRef.current = null;
658
- }
989
+
990
+ // src/stamps/geometry-3d/editor/renderer/JxgRenderer.ts
991
+ var JxgRenderer;
992
+ var init_JxgRenderer = __esm({
993
+ "src/stamps/geometry-3d/editor/renderer/JxgRenderer.ts"() {
994
+ init_constraintMath();
995
+ init_faceted();
996
+ JxgRenderer = class {
997
+ constructor(scene, view) {
998
+ this.scene = scene;
999
+ this.view = view;
1000
+ this.map = /* @__PURE__ */ new Map();
1001
+ this.unsubAdd = scene.on("add", (o) => this.handleAdd(o));
1002
+ this.unsubChange = scene.on("change", (o) => this.handleChange(o));
1003
+ this.unsubDelete = scene.on("delete", (id) => this.handleDelete(id));
1004
+ this.unsubReset = scene.on("reset", () => this.handleReset());
1005
+ for (const obj of scene.list()) this.handleAdd(obj);
1006
+ }
1007
+ dispose() {
1008
+ this.unsubAdd();
1009
+ this.unsubChange();
1010
+ this.unsubDelete();
1011
+ this.unsubReset();
1012
+ for (const [id, j] of this.map) {
659
1013
  try {
660
- if (board && JXG) JXG.JSXGraph.freeBoard(board);
1014
+ j.remove?.();
661
1015
  } catch {
662
1016
  }
663
- boardRef.current = null;
664
- viewRef.current = null;
665
- ctxRef.current = null;
666
- objMapRef.current.clear();
667
- };
668
- }, []);
669
- const handleRef = react.useRef(null);
670
- handleRef.current = {
671
- getContainer: () => containerRef.current,
672
- getTool: () => toolRef.current,
673
- setTool: (t) => {
674
- toolRef.current = t;
675
- notify();
676
- },
677
- // Sync toạ độ live của free point3d về log trước khi trả ra. JSXGraph
678
- // cho phép drag point3d (parents=[x,y,z] không có ref), việc drag chỉ
679
- // cập nhật obj.X()/Y()/Z() chứ không đụng log → re-edit + Chèn sẽ
680
- // serialize toạ độ cũ → SVG không đổi → fileId trùng → user thấy
681
- // "k thay đổi". Line/plane/polygon/sphere tham chiếu point qua @id nên
682
- // auto-update theo.
683
- getCreationLog: () => logRef.current.map((e) => {
684
- if (e.type !== "point3d") return { ...e };
685
- const parents = e.parents;
686
- if (!Array.isArray(parents) || parents.length !== 3) return { ...e };
687
- if (typeof parents[0] !== "number" || typeof parents[1] !== "number" || typeof parents[2] !== "number") return { ...e };
688
- const obj = objMapRef.current.get(e.id);
689
- if (!obj || typeof obj.X !== "function" || typeof obj.Y !== "function" || typeof obj.Z !== "function") return { ...e };
690
- const x = obj.X();
691
- const y = obj.Y();
692
- const z = obj.Z();
693
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return { ...e };
694
- return { ...e, parents: [x, y, z] };
695
- }),
696
- pushLog: (e) => {
697
- logRef.current.push(e);
698
- notify();
699
- },
700
- getViewState: () => {
701
- const v = viewRef.current;
702
- return {
703
- azimuth: v?.az?.Value?.() ?? DEFAULT_VIEW3D.azimuth,
704
- elevation: v?.el?.Value?.() ?? DEFAULT_VIEW3D.elevation,
705
- bbox3D: initialBbox3D.current
1017
+ this.map.delete(id);
1018
+ }
1019
+ }
1020
+ handleAdd(obj) {
1021
+ if (this.map.has(obj.id)) return;
1022
+ if (obj.kind === "point") {
1023
+ const world = constraintToWorld(obj.constraint, this.scene);
1024
+ const attrs = { id: obj.id, name: obj.label, size: 4, visible: obj.visible, fixed: true };
1025
+ const jxg = this.view.create("point3d", world, attrs);
1026
+ this.map.set(obj.id, jxg);
1027
+ return;
1028
+ }
1029
+ if (obj.kind === "segment") {
1030
+ const a = this.map.get(obj.p1);
1031
+ const b = this.map.get(obj.p2);
1032
+ const attrs = {
1033
+ id: obj.id,
1034
+ straightFirst: false,
1035
+ straightLast: false,
1036
+ visible: obj.visible,
1037
+ strokeColor: obj.color ?? "#0066cc",
1038
+ strokeWidth: 2
706
1039
  };
707
- },
708
- getBbox: () => [-6, 6, 6, -6],
709
- getShowAxes: () => showAxes,
710
- getShowMesh: () => showMesh,
711
- setShowAxes: (b) => {
712
- setShowAxes(b);
713
- notify();
714
- },
715
- setShowMesh: (b) => {
716
- setShowMesh(b);
717
- notify();
718
- },
719
- resetView: () => {
720
- notify();
721
- },
722
- undo: () => {
723
- logRef.current.pop();
724
- notify();
725
- },
726
- canUndo: () => logRef.current.length > 0,
727
- snapshotSVG: () => {
728
- const div = containerRef.current;
729
- if (!div) return { svgString: "", width: 0, height: 0 };
730
- const svg = div.querySelector("svg");
731
- if (!svg) return { svgString: "", width: 0, height: 0 };
732
- const clone = svg.cloneNode(true);
733
- const rect = svg.getBoundingClientRect();
734
- const width = rect.width || 600;
735
- const height = rect.height || 600;
736
- clone.setAttribute("width", String(width));
737
- clone.setAttribute("height", String(height));
738
- return {
739
- svgString: new XMLSerializer().serializeToString(clone),
740
- width,
741
- height
1040
+ this.map.set(obj.id, this.view.create("line3d", [a, b], attrs));
1041
+ return;
1042
+ }
1043
+ if (obj.kind === "line") {
1044
+ const attrs = {
1045
+ id: obj.id,
1046
+ visible: obj.visible,
1047
+ strokeColor: obj.color ?? "#0066cc",
1048
+ strokeWidth: 2
742
1049
  };
743
- },
744
- subscribe: (cb) => {
745
- subsRef.current.add(cb);
746
- return () => {
747
- subsRef.current.delete(cb);
1050
+ this.map.set(
1051
+ obj.id,
1052
+ this.view.create("line3d", [this.map.get(obj.p1), this.map.get(obj.p2)], attrs)
1053
+ );
1054
+ return;
1055
+ }
1056
+ if (obj.kind === "ray") {
1057
+ const attrs = { id: obj.id, straightFirst: false, visible: obj.visible };
1058
+ this.map.set(
1059
+ obj.id,
1060
+ this.view.create("line3d", [this.map.get(obj.origin), this.map.get(obj.through)], attrs)
1061
+ );
1062
+ return;
1063
+ }
1064
+ if (obj.kind === "vector") {
1065
+ const attrs = {
1066
+ id: obj.id,
1067
+ lastArrow: true,
1068
+ straightFirst: false,
1069
+ straightLast: false,
1070
+ visible: obj.visible
748
1071
  };
1072
+ this.map.set(
1073
+ obj.id,
1074
+ this.view.create("line3d", [this.map.get(obj.from), this.map.get(obj.to)], attrs)
1075
+ );
1076
+ return;
749
1077
  }
750
- };
751
- react.useImperativeHandle(ref, () => handleRef.current, []);
752
- const p = paletteFor2(isDark);
753
- return /* @__PURE__ */ jsxRuntime.jsx(
754
- "div",
755
- {
756
- ref: containerRef,
757
- id: containerId,
758
- style: {
759
- width: "100%",
760
- height: "100%",
761
- background: p.view3dBg,
762
- position: "relative",
763
- // Clip JSXGraph mesh3d/bounding-box paths that project outside the
764
- // board container (Bug #4) without this they overlap LeftPanel and
765
- // block pointer events.
766
- overflow: "hidden"
1078
+ if (obj.kind === "plane") {
1079
+ const attrs = { id: obj.id, fillOpacity: 0.2, visible: obj.visible };
1080
+ this.map.set(
1081
+ obj.id,
1082
+ this.view.create(
1083
+ "plane3d",
1084
+ [this.map.get(obj.p1), this.map.get(obj.p2), this.map.get(obj.p3)],
1085
+ attrs
1086
+ )
1087
+ );
1088
+ return;
1089
+ }
1090
+ if (obj.kind === "polygon") {
1091
+ const refs = obj.vertices.map((v) => this.map.get(v));
1092
+ const attrs = { id: obj.id, fillOpacity: 0.3, visible: obj.visible };
1093
+ this.map.set(obj.id, this.view.create("polygon3d", [refs], attrs));
1094
+ return;
1095
+ }
1096
+ if (obj.kind === "sphere") {
1097
+ const attrs = { id: obj.id, fillOpacity: 0.25, visible: obj.visible };
1098
+ this.map.set(
1099
+ obj.id,
1100
+ this.view.create("sphere3d", [this.map.get(obj.center), this.map.get(obj.surfacePoint)], attrs)
1101
+ );
1102
+ return;
1103
+ }
1104
+ if (obj.kind === "polyhedron") {
1105
+ const verts = obj.vertices.map((id) => this.map.get(id));
1106
+ const faceJxgs = obj.faces.map(
1107
+ (face) => this.view.create("polygon3d", [face.map((idx) => verts[idx])], {
1108
+ id: `${obj.id}.face${face.join("-")}`,
1109
+ fillOpacity: 0.25,
1110
+ strokeColor: "#0066cc",
1111
+ strokeWidth: 1.5,
1112
+ visible: obj.visible
1113
+ })
1114
+ );
1115
+ this.map.set(obj.id, {
1116
+ _faces: faceJxgs,
1117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1118
+ remove: () => faceJxgs.forEach((f) => f.remove?.())
1119
+ });
1120
+ return;
1121
+ }
1122
+ if (obj.kind === "cylinder" || obj.kind === "cone") {
1123
+ const baseCenterPt = this.scene.get(obj.baseCenter);
1124
+ if (!baseCenterPt || baseCenterPt.kind !== "point") return;
1125
+ const base = constraintToWorld(baseCenterPt.constraint, this.scene);
1126
+ let secondPt;
1127
+ if (obj.kind === "cylinder") {
1128
+ const topCenterPt = this.scene.get(obj.topCenter);
1129
+ if (!topCenterPt || topCenterPt.kind !== "point") return;
1130
+ secondPt = constraintToWorld(topCenterPt.constraint, this.scene);
1131
+ } else {
1132
+ const apexPt = this.scene.get(obj.apex);
1133
+ if (!apexPt || apexPt.kind !== "point") return;
1134
+ secondPt = constraintToWorld(apexPt.constraint, this.scene);
1135
+ }
1136
+ const geom = obj.kind === "cylinder" ? cylinderFaces(base, secondPt, obj.radius) : coneFaces(base, secondPt, obj.radius);
1137
+ const vertJxgs = geom.vertices.map(
1138
+ (v, i) => this.view.create("point3d", v, {
1139
+ id: `${obj.id}.v${i}`,
1140
+ visible: false,
1141
+ fixed: true,
1142
+ withLabel: false
1143
+ })
1144
+ );
1145
+ const faceJxgs = geom.faces.map(
1146
+ (face) => this.view.create("polygon3d", [face.map((idx) => vertJxgs[idx])], {
1147
+ id: `${obj.id}.face${face.join("-")}`,
1148
+ fillOpacity: 0.25,
1149
+ strokeColor: "#0066cc",
1150
+ strokeWidth: 1.5,
1151
+ visible: obj.visible
1152
+ })
1153
+ );
1154
+ this.map.set(obj.id, {
1155
+ _verts: vertJxgs,
1156
+ _faces: faceJxgs,
1157
+ remove: () => {
1158
+ faceJxgs.forEach((f) => f.remove?.());
1159
+ vertJxgs.forEach((v) => v.remove?.());
1160
+ }
1161
+ });
1162
+ return;
1163
+ }
1164
+ }
1165
+ handleChange(obj) {
1166
+ const j = this.map.get(obj.id);
1167
+ if (!j) return;
1168
+ if (obj.kind === "point" && typeof j.moveTo === "function") {
1169
+ const w = constraintToWorld(obj.constraint, this.scene);
1170
+ try {
1171
+ j.moveTo([w[0], w[1], w[2]], 0);
1172
+ } catch {
767
1173
  }
768
1174
  }
769
- );
770
- });
1175
+ }
1176
+ handleDelete(id) {
1177
+ const j = this.map.get(id);
1178
+ if (!j) return;
1179
+ try {
1180
+ j.remove?.();
1181
+ } catch {
1182
+ }
1183
+ this.map.delete(id);
1184
+ }
1185
+ handleReset() {
1186
+ for (const [, j] of this.map) {
1187
+ try {
1188
+ j.remove?.();
1189
+ } catch {
1190
+ }
1191
+ }
1192
+ this.map.clear();
1193
+ }
1194
+ };
771
1195
  }
772
1196
  });
773
- var EditorPanel;
774
- var init_EditorPanel = __esm({
775
- "src/stamps/geometry-3d/editor/EditorPanel.tsx"() {
1197
+
1198
+ // src/stamps/geometry-3d/editor/hitTest/rayCast.ts
1199
+ function screenToRay(screen, view) {
1200
+ const near = unproject(screen, view, 20);
1201
+ const far = unproject(screen, view, -20);
1202
+ const dir = [far[0] - near[0], far[1] - near[1], far[2] - near[2]];
1203
+ const n = Math.sqrt(dir[0] ** 2 + dir[1] ** 2 + dir[2] ** 2);
1204
+ const norm3 = n === 0 ? [0, 0, -1] : [dir[0] / n, dir[1] / n, dir[2] / n];
1205
+ return { origin: near, dir: norm3 };
1206
+ }
1207
+ function unproject(screen, view, depth) {
1208
+ if (typeof view.unprojectScreen === "function") {
1209
+ const v = view.unprojectScreen(screen.x, screen.y, depth);
1210
+ return [v[0], v[1], v[2]];
1211
+ }
1212
+ if (typeof view.project3DTo2D === "function") {
1213
+ const p0 = view.project3DTo2D(0, 0, 0);
1214
+ const px = view.project3DTo2D(1, 0, 0);
1215
+ const py = view.project3DTo2D(0, 1, 0);
1216
+ const pz = view.project3DTo2D(0, 0, 1);
1217
+ const ox = p0[1], oy = p0[2];
1218
+ const a = px[1] - ox, b = py[1] - ox, c = pz[1] - ox;
1219
+ const d = px[2] - oy, e = py[2] - oy, f = pz[2] - oy;
1220
+ const rhsX = screen.x - ox - c * depth;
1221
+ const rhsY = screen.y - oy - f * depth;
1222
+ const det = a * e - b * d;
1223
+ if (Math.abs(det) < 1e-9) return [0, 0, depth];
1224
+ const x = (e * rhsX - b * rhsY) / det;
1225
+ const y = (-d * rhsX + a * rhsY) / det;
1226
+ return [x, y, depth];
1227
+ }
1228
+ throw new Error("rayCast: view has neither unprojectScreen nor project3DTo2D");
1229
+ }
1230
+ var init_rayCast = __esm({
1231
+ "src/stamps/geometry-3d/editor/hitTest/rayCast.ts"() {
1232
+ }
1233
+ });
1234
+
1235
+ // src/stamps/geometry-3d/editor/hitTest/intersect.ts
1236
+ function dot2(a, b) {
1237
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1238
+ }
1239
+ function sub2(a, b) {
1240
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1241
+ }
1242
+ function add2(a, b) {
1243
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
1244
+ }
1245
+ function scale2(a, k) {
1246
+ return [a[0] * k, a[1] * k, a[2] * k];
1247
+ }
1248
+ function norm2(a) {
1249
+ return dot2(a, a);
1250
+ }
1251
+ function rayPlane(ray, plane) {
1252
+ const denom = dot2(ray.dir, plane.normal);
1253
+ if (Math.abs(denom) < EPS2) return null;
1254
+ const t = dot2(sub2(plane.point, ray.origin), plane.normal) / denom;
1255
+ if (t < 0) return null;
1256
+ return { point: add2(ray.origin, scale2(ray.dir, t)), t };
1257
+ }
1258
+ function rayGround(ray) {
1259
+ return rayPlane(ray, { point: [0, 0, 0], normal: [0, 0, 1] });
1260
+ }
1261
+ function raySphere(ray, sphere) {
1262
+ const oc = sub2(ray.origin, sphere.center);
1263
+ const b = dot2(oc, ray.dir);
1264
+ const c = dot2(oc, oc) - sphere.radius * sphere.radius;
1265
+ const disc = b * b - c;
1266
+ if (disc < 0) return null;
1267
+ const sqrtD = Math.sqrt(disc);
1268
+ const t1 = -b - sqrtD;
1269
+ const t2 = -b + sqrtD;
1270
+ const t = t1 >= 0 ? t1 : t2;
1271
+ if (t < 0) return null;
1272
+ return { point: add2(ray.origin, scale2(ray.dir, t)), t };
1273
+ }
1274
+ function rayLineSegment(ray, seg, maxDistance) {
1275
+ const u = ray.dir;
1276
+ const v = sub2(seg.b, seg.a);
1277
+ const w0 = sub2(ray.origin, seg.a);
1278
+ const a = dot2(u, u);
1279
+ const bb = dot2(u, v);
1280
+ const cc = dot2(v, v);
1281
+ const d = dot2(u, w0);
1282
+ const e = dot2(v, w0);
1283
+ const denom = a * cc - bb * bb;
1284
+ if (Math.abs(denom) < EPS2) return null;
1285
+ const sc = (bb * e - cc * d) / denom;
1286
+ const tc = (a * e - bb * d) / denom;
1287
+ if (sc < 0 || tc < 0 || tc > 1) return null;
1288
+ const pRay = add2(ray.origin, scale2(u, sc));
1289
+ const pSeg = add2(seg.a, scale2(v, tc));
1290
+ const dist2 = norm2(sub2(pRay, pSeg));
1291
+ if (dist2 > maxDistance * maxDistance) return null;
1292
+ return { point: pSeg, t: sc, tOnSegment: tc };
1293
+ }
1294
+ var EPS2;
1295
+ var init_intersect = __esm({
1296
+ "src/stamps/geometry-3d/editor/hitTest/intersect.ts"() {
1297
+ EPS2 = 1e-9;
1298
+ }
1299
+ });
1300
+
1301
+ // src/stamps/geometry-3d/editor/hitTest/snapping.ts
1302
+ function findSnapPoint(screen, view, scene, pixelRadius = 8) {
1303
+ const board = view?.board;
1304
+ const ux = typeof board?.unitX === "number" && board.unitX > 0 ? board.unitX : 1;
1305
+ const uy = typeof board?.unitY === "number" && board.unitY > 0 ? board.unitY : ux;
1306
+ const rxUser = pixelRadius / ux;
1307
+ const ryUser = pixelRadius / uy;
1308
+ let best = null;
1309
+ for (const obj of scene.list()) {
1310
+ if (obj.kind !== "point") continue;
1311
+ if (!obj.visible) continue;
1312
+ const world = constraintToWorld(obj.constraint, scene);
1313
+ const proj = view.project3DTo2D?.(world[0], world[1], world[2]);
1314
+ if (!proj) continue;
1315
+ const dxN = (proj[1] - screen.x) / rxUser;
1316
+ const dyN = (proj[2] - screen.y) / ryUser;
1317
+ const d2 = dxN * dxN + dyN * dyN;
1318
+ if (d2 <= 1 && (best === null || d2 < best.d2)) {
1319
+ best = { id: obj.id, d2 };
1320
+ }
1321
+ }
1322
+ return best?.id ?? null;
1323
+ }
1324
+ var init_snapping = __esm({
1325
+ "src/stamps/geometry-3d/editor/hitTest/snapping.ts"() {
1326
+ init_constraintMath();
1327
+ }
1328
+ });
1329
+
1330
+ // src/stamps/geometry-3d/editor/hitTest/hitTest.ts
1331
+ function hitTest(screen, view, scene) {
1332
+ const board = view?.board;
1333
+ const ux = typeof board?.unitX === "number" && board.unitX > 0 ? board.unitX : 1;
1334
+ const axisThresholdUser = AXIS_PIXEL_THRESHOLD / ux;
1335
+ const snap = findSnapPoint(screen, view, scene);
1336
+ if (snap) return { kind: "existingPoint", pointId: snap };
1337
+ const ray = screenToRay(screen, view);
1338
+ let bestSphere = null;
1339
+ for (const obj of scene.list()) {
1340
+ if (obj.kind !== "sphere" || !obj.visible) continue;
1341
+ const centerPoint = scene.get(obj.center);
1342
+ const surfacePoint = scene.get(obj.surfacePoint);
1343
+ if (!centerPoint || centerPoint.kind !== "point") continue;
1344
+ if (!surfacePoint || surfacePoint.kind !== "point") continue;
1345
+ const center = constraintToWorld(centerPoint.constraint, scene);
1346
+ const surface = constraintToWorld(surfacePoint.constraint, scene);
1347
+ const radius = Math.hypot(
1348
+ surface[0] - center[0],
1349
+ surface[1] - center[1],
1350
+ surface[2] - center[2]
1351
+ );
1352
+ const sh = raySphere(ray, { center, radius });
1353
+ if (sh && (bestSphere === null || sh.t < bestSphere.t)) {
1354
+ bestSphere = { id: obj.id, t: sh.t, world: sh.point };
1355
+ }
1356
+ }
1357
+ if (view.project3DTo2D) {
1358
+ const axes = [
1359
+ { axis: "x", a: [-10, 0, 0], b: [10, 0, 0] },
1360
+ { axis: "y", a: [0, -10, 0], b: [0, 10, 0] },
1361
+ { axis: "z", a: [0, 0, -10], b: [0, 0, 10] }
1362
+ ];
1363
+ for (const ax of axes) {
1364
+ const pa = view.project3DTo2D(ax.a[0], ax.a[1], ax.a[2]);
1365
+ const pb = view.project3DTo2D(ax.b[0], ax.b[1], ax.b[2]);
1366
+ const d = distScreenPointToSegment(screen, [pa[1], pa[2]], [pb[1], pb[2]]);
1367
+ if (d <= axisThresholdUser) {
1368
+ const hit = rayLineSegment(ray, { a: ax.a, b: ax.b }, 1e3);
1369
+ if (hit) {
1370
+ const t = ax.axis === "x" ? hit.point[0] : ax.axis === "y" ? hit.point[1] : hit.point[2];
1371
+ return { kind: "onAxis", axis: ax.axis, t, world: hit.point };
1372
+ }
1373
+ }
1374
+ }
1375
+ }
1376
+ let bestPlane = null;
1377
+ for (const obj of scene.list()) {
1378
+ if (obj.kind !== "plane" || !obj.visible) continue;
1379
+ const basis = planeBasis(obj, scene);
1380
+ if (!basis) continue;
1381
+ const ph = rayPlane(ray, { point: basis.origin, normal: basis.normal });
1382
+ if (ph && (bestPlane === null || ph.t < bestPlane.t)) {
1383
+ bestPlane = { id: obj.id, t: ph.t, world: ph.point, basis };
1384
+ }
1385
+ }
1386
+ if (bestPlane && (!bestSphere || bestPlane.t < bestSphere.t)) {
1387
+ const rel = [
1388
+ bestPlane.world[0] - bestPlane.basis.origin[0],
1389
+ bestPlane.world[1] - bestPlane.basis.origin[1],
1390
+ bestPlane.world[2] - bestPlane.basis.origin[2]
1391
+ ];
1392
+ const b1n = dot3(bestPlane.basis.basis1, bestPlane.basis.basis1);
1393
+ const b2n = dot3(bestPlane.basis.basis2, bestPlane.basis.basis2);
1394
+ const u = b1n === 0 ? 0 : dot3(rel, bestPlane.basis.basis1) / b1n;
1395
+ const v = b2n === 0 ? 0 : dot3(rel, bestPlane.basis.basis2) / b2n;
1396
+ return { kind: "onPlane", planeId: bestPlane.id, u, v, world: bestPlane.world };
1397
+ }
1398
+ if (bestSphere) {
1399
+ const sph = scene.get(bestSphere.id);
1400
+ if (sph && sph.kind === "sphere") {
1401
+ const centerPt = scene.get(sph.center);
1402
+ if (centerPt && centerPt.kind === "point") {
1403
+ const center = constraintToWorld(centerPt.constraint, scene);
1404
+ const relX = bestSphere.world[0] - center[0];
1405
+ const relY = bestSphere.world[1] - center[1];
1406
+ const relZ = bestSphere.world[2] - center[2];
1407
+ const r = Math.hypot(relX, relY, relZ);
1408
+ const phi = r === 0 ? 0 : Math.acos(relZ / r);
1409
+ const theta = Math.atan2(relY, relX);
1410
+ return { kind: "onSphere", sphereId: bestSphere.id, theta, phi, world: bestSphere.world };
1411
+ }
1412
+ }
1413
+ }
1414
+ const g = rayGround(ray);
1415
+ if (g) return { kind: "onGround", world: g.point };
1416
+ return { kind: "empty" };
1417
+ }
1418
+ function distScreenPointToSegment(p, a, b) {
1419
+ const vx = b[0] - a[0];
1420
+ const vy = b[1] - a[1];
1421
+ const wx = p.x - a[0];
1422
+ const wy = p.y - a[1];
1423
+ const c1 = vx * wx + vy * wy;
1424
+ if (c1 <= 0) return Math.hypot(wx, wy);
1425
+ const c2 = vx * vx + vy * vy;
1426
+ if (c2 <= c1) return Math.hypot(p.x - b[0], p.y - b[1]);
1427
+ const t = c1 / c2;
1428
+ const px = a[0] + t * vx;
1429
+ const py = a[1] + t * vy;
1430
+ return Math.hypot(p.x - px, p.y - py);
1431
+ }
1432
+ function planeBasis(planeObj, scene) {
1433
+ const p1Obj = scene.get(planeObj.p1);
1434
+ const p2Obj = scene.get(planeObj.p2);
1435
+ const p3Obj = scene.get(planeObj.p3);
1436
+ if (!p1Obj || p1Obj.kind !== "point") return null;
1437
+ if (!p2Obj || p2Obj.kind !== "point") return null;
1438
+ if (!p3Obj || p3Obj.kind !== "point") return null;
1439
+ const p1 = constraintToWorld(p1Obj.constraint, scene);
1440
+ const p2 = constraintToWorld(p2Obj.constraint, scene);
1441
+ const p3 = constraintToWorld(p3Obj.constraint, scene);
1442
+ const basis1 = [p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]];
1443
+ const tmp = [p3[0] - p1[0], p3[1] - p1[1], p3[2] - p1[2]];
1444
+ const cx = basis1[1] * tmp[2] - basis1[2] * tmp[1];
1445
+ const cy = basis1[2] * tmp[0] - basis1[0] * tmp[2];
1446
+ const cz = basis1[0] * tmp[1] - basis1[1] * tmp[0];
1447
+ const cLen = Math.hypot(cx, cy, cz);
1448
+ if (cLen === 0) return null;
1449
+ const normal = [cx / cLen, cy / cLen, cz / cLen];
1450
+ const basis2 = [
1451
+ normal[1] * basis1[2] - normal[2] * basis1[1],
1452
+ normal[2] * basis1[0] - normal[0] * basis1[2],
1453
+ normal[0] * basis1[1] - normal[1] * basis1[0]
1454
+ ];
1455
+ return { origin: p1, basis1, basis2, normal };
1456
+ }
1457
+ function dot3(a, b) {
1458
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1459
+ }
1460
+ var AXIS_PIXEL_THRESHOLD;
1461
+ var init_hitTest = __esm({
1462
+ "src/stamps/geometry-3d/editor/hitTest/hitTest.ts"() {
1463
+ init_rayCast();
1464
+ init_intersect();
1465
+ init_snapping();
1466
+ init_constraintMath();
1467
+ AXIS_PIXEL_THRESHOLD = 12;
1468
+ }
1469
+ });
1470
+ var MiniBoard3D;
1471
+ var init_MiniBoard3D = __esm({
1472
+ "src/stamps/geometry-3d/editor/MiniBoard3D.tsx"() {
776
1473
  "use client";
777
- init_MiniBoard3D();
778
- EditorPanel = react.forwardRef(function EditorPanel2({ isDark, initial, onInsert, onClose, isMobile = false, withLeftPanel = false, onBoardReady, onOpenDrawer }, ref) {
779
- const boardRef = react.useRef(null);
780
- const [ready, setReady] = react.useState(false);
781
- const onBoardReadyRef = react.useRef(onBoardReady);
782
- onBoardReadyRef.current = onBoardReady;
783
- const setBoard = react.useCallback((h) => {
784
- boardRef.current = h;
785
- setReady(!!h);
786
- onBoardReadyRef.current?.(h);
787
- }, []);
788
- const performInsert = react.useCallback(() => {
789
- const board = boardRef.current;
790
- if (!board) return false;
791
- const log = board.getCreationLog();
792
- if (log.length === 0) return false;
793
- const view = board.getViewState();
794
- const state = {
795
- version: 1,
796
- bbox: board.getBbox(),
797
- view,
798
- showAxes: board.getShowAxes(),
799
- showMesh: board.getShowMesh(),
800
- elements: log
801
- };
802
- const snap = board.snapshotSVG();
803
- onInsert(JSON.stringify(state), snap.svgString, snap.width, snap.height);
804
- return true;
805
- }, [onInsert]);
806
- react.useImperativeHandle(
807
- ref,
808
- () => ({
809
- tryInsert: performInsert,
810
- hasContent: () => (boardRef.current?.getCreationLog().length ?? 0) > 0
811
- }),
812
- [performInsert]
813
- );
814
- const handleInsert = react.useCallback(() => {
815
- performInsert();
816
- }, [performInsert]);
817
- const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
818
- position: "absolute",
819
- top: "50%",
820
- left: withLeftPanel ? "calc(50% + 120px)" : "50%",
821
- transform: "translate(-50%, -50%)",
822
- zIndex: 40
823
- };
824
- return /* @__PURE__ */ jsxRuntime.jsxs(
825
- "div",
826
- {
827
- role: "dialog",
828
- "aria-label": "D\u1EF1ng h\xECnh h\u1ECDc 3D",
829
- "data-testid": "geom3d-editor-panel",
830
- "data-stamp-area": "true",
831
- "data-mobile-editor": isMobile ? "true" : void 0,
832
- style: wrapperStyle,
833
- className: [
834
- isDark ? "theme--dark " : "",
835
- "flex flex-col overflow-hidden bg-white",
836
- isMobile ? "h-full w-full" : "h-[600px] max-h-[85vh] w-[760px] max-w-[calc(100vw-280px)] rounded-lg border border-slate-300 shadow-2xl ring-1 ring-black/5"
837
- ].join(" "),
838
- children: [
839
- /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center gap-2 border-b border-slate-200 bg-gradient-to-r from-blue-600 to-cyan-600 px-3 py-2 text-white", children: [
840
- isMobile && /* @__PURE__ */ jsxRuntime.jsx(
841
- "button",
1474
+ init_theme2();
1475
+ MiniBoard3D = React2__namespace.forwardRef(
1476
+ function MiniBoard3D2(props, ref) {
1477
+ const containerRef = React2__namespace.useRef(null);
1478
+ const boardRef = React2__namespace.useRef(null);
1479
+ const viewRef = React2__namespace.useRef(null);
1480
+ const {
1481
+ isDark,
1482
+ onView3DReady,
1483
+ onPointerClick,
1484
+ onPointerMove,
1485
+ onPointerLeave,
1486
+ shouldStartPointDrag,
1487
+ onPointerDrag,
1488
+ onPointerDragEnd
1489
+ } = props;
1490
+ const onView3DReadyRef = React2__namespace.useRef(onView3DReady);
1491
+ const onPointerClickRef = React2__namespace.useRef(onPointerClick);
1492
+ const onPointerMoveRef = React2__namespace.useRef(onPointerMove);
1493
+ const onPointerLeaveRef = React2__namespace.useRef(onPointerLeave);
1494
+ const shouldStartPointDragRef = React2__namespace.useRef(shouldStartPointDrag);
1495
+ const onPointerDragRef = React2__namespace.useRef(onPointerDrag);
1496
+ const onPointerDragEndRef = React2__namespace.useRef(onPointerDragEnd);
1497
+ onView3DReadyRef.current = onView3DReady;
1498
+ onPointerClickRef.current = onPointerClick;
1499
+ onPointerMoveRef.current = onPointerMove;
1500
+ onPointerLeaveRef.current = onPointerLeave;
1501
+ shouldStartPointDragRef.current = shouldStartPointDrag;
1502
+ onPointerDragRef.current = onPointerDrag;
1503
+ onPointerDragEndRef.current = onPointerDragEnd;
1504
+ React2__namespace.useImperativeHandle(
1505
+ ref,
1506
+ () => ({
1507
+ getBoard: () => boardRef.current,
1508
+ getView3D: () => viewRef.current,
1509
+ getSvgElement: () => containerRef.current?.querySelector("svg") ?? null
1510
+ }),
1511
+ []
1512
+ );
1513
+ React2__namespace.useEffect(() => {
1514
+ const div = containerRef.current;
1515
+ if (!div) return;
1516
+ let cancelled = false;
1517
+ let JXG = null;
1518
+ let board = null;
1519
+ let svgEl = null;
1520
+ let handlePointerDown = null;
1521
+ let handlePointerMove = null;
1522
+ let handlePointerUp = null;
1523
+ let handlePointerLeave = null;
1524
+ void (async () => {
1525
+ try {
1526
+ JXG = (await import('jsxgraph')).default;
1527
+ } catch {
1528
+ return;
1529
+ }
1530
+ if (cancelled || !containerRef.current) return;
1531
+ try {
1532
+ JXG.Options.text.display = "internal";
1533
+ } catch {
1534
+ }
1535
+ try {
1536
+ board = JXG.JSXGraph.initBoard(div, {
1537
+ boundingbox: [-6, 6, 6, -6],
1538
+ keepaspectratio: true,
1539
+ axis: false,
1540
+ showCopyright: false,
1541
+ showNavigation: false,
1542
+ renderer: "svg"
1543
+ });
1544
+ } catch {
1545
+ return;
1546
+ }
1547
+ if (cancelled || !board) return;
1548
+ boardRef.current = board;
1549
+ let view = null;
1550
+ try {
1551
+ const baseAttrs = VIEW3D_ATTRS(isDark);
1552
+ view = board.create(
1553
+ "view3d",
1554
+ [
1555
+ [-5, -5],
1556
+ [10, 10],
1557
+ [
1558
+ [DEFAULT_VIEW3D.bbox3D[0], DEFAULT_VIEW3D.bbox3D[3]],
1559
+ [DEFAULT_VIEW3D.bbox3D[1], DEFAULT_VIEW3D.bbox3D[4]],
1560
+ [DEFAULT_VIEW3D.bbox3D[2], DEFAULT_VIEW3D.bbox3D[5]]
1561
+ ]
1562
+ ],
842
1563
  {
843
- type: "button",
844
- onClick: onOpenDrawer,
845
- "aria-label": "M\u1EDF ng\u0103n c\xF4ng c\u1EE5",
846
- className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
847
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
848
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
849
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
850
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
851
- ] })
1564
+ ...baseAttrs,
1565
+ az: { ...baseAttrs.az, value: DEFAULT_VIEW3D.azimuth },
1566
+ el: { ...baseAttrs.el, value: DEFAULT_VIEW3D.elevation }
852
1567
  }
853
- ),
854
- /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
855
- /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 7 L14 4 L20 7 L14 10 Z M4 7 L4 17 L14 20 L14 10 M14 20 L20 17 L20 7" }) }),
856
- "H\xECnh h\u1ECDc kh\xF4ng gian (3D)"
857
- ] }),
858
- isMobile && /* @__PURE__ */ jsxRuntime.jsx(
859
- "button",
860
- {
861
- type: "button",
862
- onClick: handleInsert,
863
- disabled: !ready,
864
- "data-testid": "geom3d-insert-btn-mobile",
865
- className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
866
- children: "Ch\xE8n"
1568
+ );
1569
+ } catch {
1570
+ }
1571
+ viewRef.current = view;
1572
+ if (view) {
1573
+ try {
1574
+ view.create(
1575
+ "plane3d",
1576
+ [
1577
+ [0, 0, 0],
1578
+ [1, 0, 0],
1579
+ [0, 1, 0],
1580
+ GROUND_PLANE_RANGE,
1581
+ GROUND_PLANE_RANGE
1582
+ ],
1583
+ GROUND_PLANE_ATTRS(isDark)
1584
+ );
1585
+ } catch {
1586
+ }
1587
+ onView3DReadyRef.current?.(view, board);
1588
+ }
1589
+ svgEl = containerRef.current?.querySelector("svg") ?? null;
1590
+ if (svgEl) {
1591
+ const p2 = paletteFor2(isDark);
1592
+ svgEl.style.background = p2.view3dBg;
1593
+ const pixelToUser = (e) => {
1594
+ const rect = svgEl.getBoundingClientRect();
1595
+ const px = e.clientX - rect.left;
1596
+ const py = e.clientY - rect.top;
1597
+ const b = board;
1598
+ if (!b || !b.origin || !b.origin.scrCoords) {
1599
+ return { x: px, y: py };
867
1600
  }
868
- ),
869
- /* @__PURE__ */ jsxRuntime.jsx(
870
- "button",
871
- {
872
- onClick: onClose,
873
- "aria-label": "\u0110\xF3ng",
874
- className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15",
875
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
876
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
877
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
878
- ] })
1601
+ const ox = b.origin.scrCoords[1];
1602
+ const oy = b.origin.scrCoords[2];
1603
+ const ux = b.unitX || 1;
1604
+ const uy = b.unitY || 1;
1605
+ return { x: (px - ox) / ux, y: (oy - py) / uy };
1606
+ };
1607
+ const DRAG_THRESHOLD = 4;
1608
+ const AZ_PER_PX = 0.01;
1609
+ const EL_PER_PX = 0.01;
1610
+ const EL_LIMIT = Math.PI / 2 - 0.05;
1611
+ let dragStart = null;
1612
+ let dragging = false;
1613
+ let pointDragMode = false;
1614
+ let startAz = 0;
1615
+ let startEl = 0;
1616
+ const readAng = (s) => {
1617
+ if (!s) return 0;
1618
+ if (typeof s.Value === "function") {
1619
+ try {
1620
+ return s.Value();
1621
+ } catch {
1622
+ }
879
1623
  }
880
- )
881
- ] }),
882
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", children: /* @__PURE__ */ jsxRuntime.jsx(MiniBoard3D, { ref: setBoard, isDark, initialState: initial }) }),
883
- !isMobile && /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
884
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-slate-500", children: "Ch\u1ECDn c\xF4ng c\u1EE5 b\xEAn tr\xE1i, click tr\xEAn b\u1EA3ng \u0111\u1EC3 d\u1EF1ng h\xECnh." }),
885
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
886
- /* @__PURE__ */ jsxRuntime.jsx(
887
- "button",
888
- {
889
- onClick: onClose,
890
- className: "rounded border border-slate-300 bg-white px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-100",
891
- children: "Hu\u1EF7"
1624
+ return typeof s.value === "number" ? s.value : 0;
1625
+ };
1626
+ const setAng = (s, v) => {
1627
+ if (!s) return;
1628
+ if (typeof s.setValue === "function") {
1629
+ try {
1630
+ s.setValue(v);
1631
+ return;
1632
+ } catch {
892
1633
  }
893
- ),
894
- /* @__PURE__ */ jsxRuntime.jsx(
895
- "button",
896
- {
897
- onClick: handleInsert,
898
- disabled: !ready,
899
- "data-testid": "geom3d-insert-btn",
900
- className: "rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white transition hover:bg-blue-700 disabled:opacity-50",
901
- children: "Ch\xE8n"
1634
+ }
1635
+ s.value = v;
1636
+ };
1637
+ handlePointerDown = (e) => {
1638
+ if (!svgEl) return;
1639
+ dragStart = { x: e.clientX, y: e.clientY };
1640
+ dragging = false;
1641
+ pointDragMode = false;
1642
+ const screen = pixelToUser(e);
1643
+ try {
1644
+ pointDragMode = shouldStartPointDragRef.current?.(screen) ?? false;
1645
+ } catch {
1646
+ pointDragMode = false;
1647
+ }
1648
+ if (!pointDragMode) {
1649
+ const v = viewRef.current;
1650
+ startAz = readAng(v?.az_slide ?? v?.az);
1651
+ startEl = readAng(v?.el_slide ?? v?.el);
1652
+ }
1653
+ try {
1654
+ svgEl.setPointerCapture?.(e.pointerId);
1655
+ } catch {
1656
+ }
1657
+ };
1658
+ handlePointerMove = (e) => {
1659
+ if (!svgEl) return;
1660
+ if (dragStart) {
1661
+ const dx = e.clientX - dragStart.x;
1662
+ const dy = e.clientY - dragStart.y;
1663
+ if (!dragging && Math.hypot(dx, dy) > DRAG_THRESHOLD) dragging = true;
1664
+ if (dragging) {
1665
+ if (pointDragMode) {
1666
+ onPointerDragRef.current?.(pixelToUser(e));
1667
+ return;
1668
+ }
1669
+ const v = viewRef.current;
1670
+ const newAz = startAz + dx * AZ_PER_PX;
1671
+ let newEl = startEl - dy * EL_PER_PX;
1672
+ if (newEl > EL_LIMIT) newEl = EL_LIMIT;
1673
+ if (newEl < -EL_LIMIT) newEl = -EL_LIMIT;
1674
+ setAng(v?.az_slide ?? v?.az, newAz);
1675
+ setAng(v?.el_slide ?? v?.el, newEl);
1676
+ try {
1677
+ v?.board?.update?.();
1678
+ } catch {
1679
+ }
1680
+ return;
902
1681
  }
903
- )
904
- ] })
905
- ] })
906
- ]
1682
+ }
1683
+ onPointerMoveRef.current?.(pixelToUser(e));
1684
+ };
1685
+ handlePointerUp = (e) => {
1686
+ if (!svgEl) return;
1687
+ const wasDrag = dragging;
1688
+ const hadDown = dragStart !== null;
1689
+ const wasPointDrag = pointDragMode;
1690
+ dragStart = null;
1691
+ dragging = false;
1692
+ pointDragMode = false;
1693
+ try {
1694
+ svgEl.releasePointerCapture?.(e.pointerId);
1695
+ } catch {
1696
+ }
1697
+ if (hadDown && wasPointDrag) {
1698
+ onPointerDragEndRef.current?.(pixelToUser(e));
1699
+ return;
1700
+ }
1701
+ if (hadDown && !wasDrag) {
1702
+ onPointerClickRef.current?.(pixelToUser(e));
1703
+ }
1704
+ };
1705
+ handlePointerLeave = () => {
1706
+ if (pointDragMode) {
1707
+ try {
1708
+ onPointerDragEndRef.current?.({ x: 0, y: 0 });
1709
+ } catch {
1710
+ }
1711
+ }
1712
+ dragStart = null;
1713
+ dragging = false;
1714
+ pointDragMode = false;
1715
+ onPointerLeaveRef.current?.();
1716
+ };
1717
+ svgEl.addEventListener("pointerdown", handlePointerDown);
1718
+ svgEl.addEventListener("pointermove", handlePointerMove);
1719
+ svgEl.addEventListener("pointerup", handlePointerUp);
1720
+ svgEl.addEventListener("pointercancel", handlePointerUp);
1721
+ svgEl.addEventListener("pointerleave", handlePointerLeave);
1722
+ }
1723
+ })();
1724
+ return () => {
1725
+ cancelled = true;
1726
+ if (svgEl) {
1727
+ if (handlePointerDown) svgEl.removeEventListener("pointerdown", handlePointerDown);
1728
+ if (handlePointerMove) svgEl.removeEventListener("pointermove", handlePointerMove);
1729
+ if (handlePointerUp) {
1730
+ svgEl.removeEventListener("pointerup", handlePointerUp);
1731
+ svgEl.removeEventListener("pointercancel", handlePointerUp);
1732
+ }
1733
+ if (handlePointerLeave) svgEl.removeEventListener("pointerleave", handlePointerLeave);
1734
+ }
1735
+ try {
1736
+ if (board && JXG) JXG.JSXGraph.freeBoard(board);
1737
+ } catch {
1738
+ }
1739
+ boardRef.current = null;
1740
+ viewRef.current = null;
1741
+ };
1742
+ }, [isDark]);
1743
+ const p = paletteFor2(isDark);
1744
+ return /* @__PURE__ */ jsxRuntime.jsx(
1745
+ "div",
1746
+ {
1747
+ "data-testid": "mini-board-3d",
1748
+ ref: containerRef,
1749
+ style: {
1750
+ width: "100%",
1751
+ height: "100%",
1752
+ minHeight: 400,
1753
+ background: p.view3dBg,
1754
+ position: "relative",
1755
+ // Clip JSXGraph mesh3d paths projecting outside the container.
1756
+ overflow: "hidden"
1757
+ }
1758
+ }
1759
+ );
1760
+ }
1761
+ );
1762
+ }
1763
+ });
1764
+ function StatusHint(props) {
1765
+ const { hint, hoverLabel } = props;
1766
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1767
+ "div",
1768
+ {
1769
+ "data-testid": "status-hint",
1770
+ className: "border-t border-zinc-200 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
1771
+ children: [
1772
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
1773
+ "\u{1F4D0} ",
1774
+ hint || "Ch\u1ECDn c\xF4ng c\u1EE5 trong b\u1EA3ng b\xEAn tr\xE1i"
1775
+ ] }),
1776
+ hoverLabel ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "ml-3 text-zinc-500", children: [
1777
+ "\u2014 \u0111ang tr\xEAn: ",
1778
+ hoverLabel
1779
+ ] }) : null
1780
+ ]
1781
+ }
1782
+ );
1783
+ }
1784
+ var init_StatusHint = __esm({
1785
+ "src/stamps/geometry-3d/editor/StatusHint.tsx"() {
1786
+ "use client";
1787
+ }
1788
+ });
1789
+
1790
+ // src/stamps/geometry-3d/editor/scene/labels.ts
1791
+ function nextPointLabel(existing) {
1792
+ const used = new Set(existing);
1793
+ for (let suffix = 0; suffix < 1e3; suffix++) {
1794
+ for (let i = 0; i < 26; i++) {
1795
+ const letter = String.fromCharCode(A + i);
1796
+ const candidate = suffix === 0 ? letter : `${letter}_${suffix}`;
1797
+ if (!used.has(candidate)) return candidate;
1798
+ }
1799
+ }
1800
+ return `P_${used.size}`;
1801
+ }
1802
+ function nextDerivedLabel(kind, existing) {
1803
+ const used = new Set(existing);
1804
+ if (LOWERCASE_KINDS.includes(kind)) {
1805
+ for (let i = 0; i < 26; i++) {
1806
+ const c = String.fromCharCode("a".charCodeAt(0) + i);
1807
+ if (!used.has(c)) return c;
1808
+ }
1809
+ for (let n = 1; n < 1e3; n++) {
1810
+ const c = `a_${n}`;
1811
+ if (!used.has(c)) return c;
1812
+ }
1813
+ }
1814
+ const prefix = PREFIX[kind] ?? kind[0];
1815
+ for (let n = 1; n < 1e3; n++) {
1816
+ const candidate = `${prefix}_${n}`;
1817
+ if (!used.has(candidate)) return candidate;
1818
+ }
1819
+ return `${prefix}_x`;
1820
+ }
1821
+ var A, LOWERCASE_KINDS, PREFIX;
1822
+ var init_labels = __esm({
1823
+ "src/stamps/geometry-3d/editor/scene/labels.ts"() {
1824
+ A = "A".charCodeAt(0);
1825
+ LOWERCASE_KINDS = ["segment", "line", "ray", "vector"];
1826
+ PREFIX = {
1827
+ sphere: "s",
1828
+ polyhedron: "h",
1829
+ cylinder: "c",
1830
+ cone: "k",
1831
+ polygon: "g",
1832
+ plane: "\u03C0"
1833
+ };
1834
+ }
1835
+ });
1836
+
1837
+ // src/stamps/geometry-3d/editor/scene/Scene3D.ts
1838
+ var Scene3D;
1839
+ var init_Scene3D = __esm({
1840
+ "src/stamps/geometry-3d/editor/scene/Scene3D.ts"() {
1841
+ init_labels();
1842
+ Scene3D = class {
1843
+ constructor() {
1844
+ this.objects = /* @__PURE__ */ new Map();
1845
+ this.order = [];
1846
+ this.counter = 0;
1847
+ this.listeners = {
1848
+ add: /* @__PURE__ */ new Set(),
1849
+ change: /* @__PURE__ */ new Set(),
1850
+ delete: /* @__PURE__ */ new Set(),
1851
+ reset: /* @__PURE__ */ new Set()
1852
+ };
1853
+ this.historyPast = [];
1854
+ this.historyFuture = [];
1855
+ this.historySuspended = false;
1856
+ this.historyListeners = /* @__PURE__ */ new Set();
1857
+ }
1858
+ on(event, cb) {
1859
+ const set = this.listeners[event];
1860
+ set.add(cb);
1861
+ return () => {
1862
+ set.delete(cb);
1863
+ };
1864
+ }
1865
+ nextId(prefix) {
1866
+ this.counter += 1;
1867
+ return `${prefix}${this.counter}`;
1868
+ }
1869
+ addPoint(constraint, label, color) {
1870
+ this.capture();
1871
+ const id = this.nextId("p");
1872
+ const existingLabels = this.list().filter((o) => o.kind === "point").map((o) => o.label);
1873
+ const autoLabel = label ?? nextPointLabel(existingLabels);
1874
+ const obj = {
1875
+ kind: "point",
1876
+ id,
1877
+ label: autoLabel,
1878
+ visible: true,
1879
+ color,
1880
+ constraint
1881
+ };
1882
+ this.objects.set(id, obj);
1883
+ this.order.push(id);
1884
+ this.listeners.add.forEach((cb) => cb(obj));
1885
+ return id;
1886
+ }
1887
+ addObject(kind, spec, label) {
1888
+ this.capture();
1889
+ const id = this.nextId(kind[0]);
1890
+ const existingLabels = this.list().filter((o) => o.kind === kind).map((o) => o.label);
1891
+ const autoLabel = label ?? nextDerivedLabel(kind, existingLabels);
1892
+ const obj = { id, label: autoLabel, visible: true, kind, ...spec };
1893
+ this.objects.set(id, obj);
1894
+ this.order.push(id);
1895
+ this.listeners.add.forEach((cb) => cb(obj));
1896
+ return id;
1897
+ }
1898
+ insert(obj) {
1899
+ this.capture();
1900
+ if (this.objects.has(obj.id)) {
1901
+ throw new Error(`Scene3D.insert: id ${obj.id} already exists`);
907
1902
  }
908
- );
909
- });
1903
+ this.objects.set(obj.id, obj);
1904
+ this.order.push(obj.id);
1905
+ this.listeners.add.forEach((cb) => cb(obj));
1906
+ }
1907
+ get(id) {
1908
+ return this.objects.get(id);
1909
+ }
1910
+ list() {
1911
+ return this.order.map((id) => this.objects.get(id)).filter((obj) => obj !== void 0);
1912
+ }
1913
+ referencedIds(obj) {
1914
+ switch (obj.kind) {
1915
+ case "point": {
1916
+ const c = obj.constraint;
1917
+ if (c.kind === "onPlane") return [c.planeId];
1918
+ if (c.kind === "onLine") return [c.lineId];
1919
+ if (c.kind === "onPolygon") return [c.polygonId];
1920
+ if (c.kind === "onSphere") return [c.sphereId];
1921
+ return [];
1922
+ }
1923
+ case "segment":
1924
+ case "line":
1925
+ return [obj.p1, obj.p2];
1926
+ case "ray":
1927
+ return [obj.origin, obj.through];
1928
+ case "vector":
1929
+ return [obj.from, obj.to];
1930
+ case "polygon":
1931
+ return obj.vertices;
1932
+ case "plane":
1933
+ return [obj.p1, obj.p2, obj.p3];
1934
+ case "sphere":
1935
+ return [obj.center, obj.surfacePoint];
1936
+ case "polyhedron":
1937
+ return obj.vertices;
1938
+ case "cylinder":
1939
+ return [obj.baseCenter, obj.topCenter];
1940
+ case "cone":
1941
+ return [obj.baseCenter, obj.apex];
1942
+ }
1943
+ }
1944
+ collectDependents(targetId) {
1945
+ const dependents = /* @__PURE__ */ new Set([targetId]);
1946
+ let grew = true;
1947
+ while (grew) {
1948
+ grew = false;
1949
+ for (const obj of this.objects.values()) {
1950
+ if (dependents.has(obj.id)) continue;
1951
+ const refs = this.referencedIds(obj);
1952
+ if (refs.some((r) => dependents.has(r))) {
1953
+ dependents.add(obj.id);
1954
+ grew = true;
1955
+ }
1956
+ }
1957
+ }
1958
+ return dependents;
1959
+ }
1960
+ delete(id) {
1961
+ if (!this.objects.has(id)) return;
1962
+ this.capture();
1963
+ const toDelete = this.collectDependents(id);
1964
+ for (const dependentId of toDelete) {
1965
+ this.objects.delete(dependentId);
1966
+ this.order = this.order.filter((x) => x !== dependentId);
1967
+ this.listeners.delete.forEach((cb) => cb(dependentId));
1968
+ }
1969
+ }
1970
+ reset() {
1971
+ this.capture();
1972
+ this.objects.clear();
1973
+ this.order = [];
1974
+ this.counter = 0;
1975
+ this.listeners.reset.forEach((cb) => cb());
1976
+ }
1977
+ reserveId(prefix) {
1978
+ return this.nextId(prefix);
1979
+ }
1980
+ emitChange(id) {
1981
+ const obj = this.objects.get(id);
1982
+ if (!obj) return;
1983
+ this.listeners.change.forEach((cb) => cb(obj));
1984
+ }
1985
+ snapshot() {
1986
+ const cloned = /* @__PURE__ */ new Map();
1987
+ for (const [id, obj] of this.objects) {
1988
+ cloned.set(id, { ...obj });
1989
+ }
1990
+ return {
1991
+ objects: cloned,
1992
+ order: [...this.order],
1993
+ counter: this.counter
1994
+ };
1995
+ }
1996
+ restore(snap) {
1997
+ this.objects = /* @__PURE__ */ new Map();
1998
+ for (const [id, obj] of snap.objects) {
1999
+ this.objects.set(id, { ...obj });
2000
+ }
2001
+ this.order = [...snap.order];
2002
+ this.counter = snap.counter;
2003
+ this.listeners.reset.forEach((cb) => cb());
2004
+ for (const id of this.order) {
2005
+ const obj = this.objects.get(id);
2006
+ if (obj) this.listeners.add.forEach((cb) => cb(obj));
2007
+ }
2008
+ }
2009
+ capture() {
2010
+ if (this.historySuspended) return;
2011
+ this.historyPast.push(this.snapshot());
2012
+ this.historyFuture = [];
2013
+ this.notifyHistoryChange();
2014
+ }
2015
+ canUndo() {
2016
+ return this.historyPast.length > 0;
2017
+ }
2018
+ canRedo() {
2019
+ return this.historyFuture.length > 0;
2020
+ }
2021
+ undo() {
2022
+ const prev = this.historyPast.pop();
2023
+ if (!prev) return;
2024
+ this.historyFuture.push(this.snapshot());
2025
+ this.restore(prev);
2026
+ this.notifyHistoryChange();
2027
+ }
2028
+ redo() {
2029
+ const next = this.historyFuture.pop();
2030
+ if (!next) return;
2031
+ this.historyPast.push(this.snapshot());
2032
+ this.restore(next);
2033
+ this.notifyHistoryChange();
2034
+ }
2035
+ withoutHistory(fn) {
2036
+ const prev = this.historySuspended;
2037
+ this.historySuspended = true;
2038
+ try {
2039
+ fn();
2040
+ } finally {
2041
+ this.historySuspended = prev;
2042
+ }
2043
+ }
2044
+ pushUndoCheckpoint(prev) {
2045
+ if (this.historySuspended) return;
2046
+ this.historyPast.push(prev);
2047
+ this.historyFuture = [];
2048
+ this.notifyHistoryChange();
2049
+ }
2050
+ onHistoryChange(cb) {
2051
+ this.historyListeners.add(cb);
2052
+ return () => {
2053
+ this.historyListeners.delete(cb);
2054
+ };
2055
+ }
2056
+ notifyHistoryChange() {
2057
+ this.historyListeners.forEach((cb) => cb());
2058
+ }
2059
+ };
2060
+ }
2061
+ });
2062
+
2063
+ // src/stamps/geometry-3d/editor/scene/persistence.ts
2064
+ function sceneToBoard(scene, view, bbox) {
2065
+ const elements = [];
2066
+ for (const obj of scene.list()) {
2067
+ const els = sceneObjectToElements(obj, scene);
2068
+ elements.push(...els);
2069
+ }
2070
+ return { version: 2, bbox, view, showAxes: true, showMesh: true, elements };
2071
+ }
2072
+ function sceneObjectToElements(obj, scene) {
2073
+ const baseAttrs = { label: obj.label, visible: obj.visible, color: obj.color };
2074
+ switch (obj.kind) {
2075
+ case "point": {
2076
+ let w;
2077
+ try {
2078
+ w = constraintToWorld(obj.constraint, scene);
2079
+ } catch {
2080
+ w = [0, 0, 0];
2081
+ }
2082
+ return [{
2083
+ type: "point3d",
2084
+ parents: [w[0], w[1], w[2]],
2085
+ attributes: { id: obj.id, ...baseAttrs },
2086
+ id: obj.id,
2087
+ label: obj.label,
2088
+ constraint: obj.constraint
2089
+ }];
2090
+ }
2091
+ case "segment":
2092
+ case "line":
2093
+ case "ray":
2094
+ case "vector":
2095
+ case "plane":
2096
+ case "sphere":
2097
+ case "polygon":
2098
+ case "polyhedron":
2099
+ case "cylinder":
2100
+ case "cone": {
2101
+ return [{
2102
+ type: pickJxgType(obj.kind),
2103
+ parents: [],
2104
+ attributes: { id: obj.id, ...baseAttrs, sceneKind: obj.kind, sceneSpec: encodeSpec(obj) },
2105
+ id: obj.id,
2106
+ label: obj.label
2107
+ }];
2108
+ }
2109
+ }
2110
+ }
2111
+ function pickJxgType(kind) {
2112
+ switch (kind) {
2113
+ case "point":
2114
+ return "point3d";
2115
+ case "segment":
2116
+ case "line":
2117
+ case "ray":
2118
+ case "vector":
2119
+ return "line3d";
2120
+ case "plane":
2121
+ return "plane3d";
2122
+ case "sphere":
2123
+ return "sphere3d";
2124
+ case "polygon":
2125
+ case "polyhedron":
2126
+ case "cylinder":
2127
+ case "cone":
2128
+ return "polygon3d";
2129
+ }
2130
+ }
2131
+ function encodeSpec(obj) {
2132
+ const rest = {};
2133
+ for (const [k, v] of Object.entries(obj)) {
2134
+ if (k === "id" || k === "label" || k === "visible" || k === "color" || k === "kind") continue;
2135
+ rest[k] = v;
2136
+ }
2137
+ return rest;
2138
+ }
2139
+ function boardToScene(board) {
2140
+ const scene = new Scene3D();
2141
+ for (const el of board.elements) {
2142
+ if (el.type === "point3d") {
2143
+ const constraint = el.constraint ?? {
2144
+ kind: "free",
2145
+ x: Number(el.parents[0] ?? 0),
2146
+ y: Number(el.parents[1] ?? 0),
2147
+ z: Number(el.parents[2] ?? 0)
2148
+ };
2149
+ const color2 = el.attributes["color"];
2150
+ const visible2 = el.attributes["visible"] !== false;
2151
+ try {
2152
+ scene.insert({
2153
+ kind: "point",
2154
+ id: el.id,
2155
+ label: el.label ?? el.id,
2156
+ visible: visible2,
2157
+ color: color2,
2158
+ constraint
2159
+ });
2160
+ } catch {
2161
+ }
2162
+ continue;
2163
+ }
2164
+ const sceneKind = el.attributes["sceneKind"];
2165
+ const sceneSpec = el.attributes["sceneSpec"];
2166
+ if (!sceneKind || !sceneSpec) continue;
2167
+ const color = el.attributes["color"];
2168
+ const visible = el.attributes["visible"] !== false;
2169
+ const obj = {
2170
+ id: el.id,
2171
+ label: el.label ?? el.id,
2172
+ visible,
2173
+ color,
2174
+ kind: sceneKind,
2175
+ ...sceneSpec
2176
+ };
2177
+ try {
2178
+ scene.insert(obj);
2179
+ } catch {
2180
+ }
2181
+ }
2182
+ return scene;
2183
+ }
2184
+ var init_persistence = __esm({
2185
+ "src/stamps/geometry-3d/editor/scene/persistence.ts"() {
2186
+ init_Scene3D();
2187
+ init_constraintMath();
2188
+ }
2189
+ });
2190
+ var EditorPanel;
2191
+ var init_EditorPanel = __esm({
2192
+ "src/stamps/geometry-3d/editor/EditorPanel.tsx"() {
2193
+ "use client";
2194
+ init_controller();
2195
+ init_JxgRenderer();
2196
+ init_hitTest();
2197
+ init_rayCast();
2198
+ init_intersect();
2199
+ init_constraintMath();
2200
+ init_ensurePoint();
2201
+ init_MiniBoard3D();
2202
+ init_StatusHint();
2203
+ init_persistence();
2204
+ EditorPanel = React2__namespace.forwardRef(
2205
+ function EditorPanel2(props, ref) {
2206
+ const {
2207
+ isDark: isDarkProp,
2208
+ initialState,
2209
+ scene,
2210
+ selectedTool,
2211
+ onSelectedToolChange,
2212
+ showAxis,
2213
+ showGrid,
2214
+ onReadyChange,
2215
+ onHistoryChange
2216
+ } = props;
2217
+ const isDark = isDarkProp ?? false;
2218
+ const controllerRef = React2__namespace.useRef(null);
2219
+ if (!controllerRef.current) controllerRef.current = new ToolController(scene);
2220
+ const [hint, setHint] = React2__namespace.useState("Ch\u1ECDn c\xF4ng c\u1EE5 trong b\u1EA3ng b\xEAn tr\xE1i");
2221
+ const [hoverLabel, setHoverLabel] = React2__namespace.useState(null);
2222
+ const boardRef = React2__namespace.useRef(null);
2223
+ const rendererRef = React2__namespace.useRef(null);
2224
+ const onSelectedToolChangeRef = React2__namespace.useRef(onSelectedToolChange);
2225
+ onSelectedToolChangeRef.current = onSelectedToolChange;
2226
+ const onHistoryChangeRef = React2__namespace.useRef(onHistoryChange);
2227
+ onHistoryChangeRef.current = onHistoryChange;
2228
+ const selectedToolRef = React2__namespace.useRef(selectedTool);
2229
+ selectedToolRef.current = selectedTool;
2230
+ const draggedPointRef = React2__namespace.useRef(null);
2231
+ const dragStartRef = React2__namespace.useRef(null);
2232
+ const dragSnapshotRef = React2__namespace.useRef(null);
2233
+ React2__namespace.useEffect(() => {
2234
+ if (initialState) {
2235
+ const loaded = boardToScene(initialState);
2236
+ scene.withoutHistory(() => {
2237
+ scene.reset();
2238
+ for (const obj of loaded.list()) {
2239
+ scene.insert(obj);
2240
+ }
2241
+ });
2242
+ }
2243
+ }, []);
2244
+ React2__namespace.useEffect(() => {
2245
+ const ctrl = controllerRef.current;
2246
+ const unsub = ctrl.on((state) => {
2247
+ setHint(state.hint);
2248
+ onSelectedToolChangeRef.current(state.tool?.key ?? "move");
2249
+ });
2250
+ return unsub;
2251
+ }, []);
2252
+ React2__namespace.useEffect(() => {
2253
+ onHistoryChangeRef.current?.(scene.canUndo(), scene.canRedo());
2254
+ const unsub = scene.onHistoryChange(() => {
2255
+ onHistoryChangeRef.current?.(scene.canUndo(), scene.canRedo());
2256
+ });
2257
+ return unsub;
2258
+ }, [scene]);
2259
+ React2__namespace.useEffect(() => {
2260
+ controllerRef.current?.selectTool(selectedTool);
2261
+ }, [selectedTool]);
2262
+ React2__namespace.useEffect(() => {
2263
+ const onKey = (e) => {
2264
+ const ae = document.activeElement;
2265
+ const inField = !!(ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA" || ae.isContentEditable));
2266
+ if (inField) return;
2267
+ if (!(e.metaKey || e.ctrlKey)) return;
2268
+ const key = e.key.toLowerCase();
2269
+ if (key === "z" && !e.shiftKey) {
2270
+ e.preventDefault();
2271
+ e.stopPropagation();
2272
+ scene.undo();
2273
+ } else if (key === "z" && e.shiftKey || key === "y" && !e.shiftKey) {
2274
+ e.preventDefault();
2275
+ e.stopPropagation();
2276
+ scene.redo();
2277
+ }
2278
+ };
2279
+ window.addEventListener("keydown", onKey, { capture: true });
2280
+ return () => window.removeEventListener("keydown", onKey, { capture: true });
2281
+ }, [scene]);
2282
+ React2__namespace.useEffect(() => {
2283
+ return () => {
2284
+ rendererRef.current?.dispose();
2285
+ rendererRef.current = null;
2286
+ };
2287
+ }, []);
2288
+ React2__namespace.useEffect(() => {
2289
+ const view = boardRef.current?.getView3D();
2290
+ const v = view;
2291
+ if (!v || typeof v.setAttribute !== "function") return;
2292
+ try {
2293
+ v.setAttribute({
2294
+ xAxis: { visible: showAxis },
2295
+ yAxis: { visible: showAxis },
2296
+ zAxis: { visible: showAxis },
2297
+ // GeoGebra-style: only the XY ground plane is shown; side walls stay hidden.
2298
+ xPlaneRear: { visible: false, mesh3d: { visible: false } },
2299
+ yPlaneRear: { visible: false, mesh3d: { visible: false } },
2300
+ zPlaneRear: { visible: showGrid, mesh3d: { visible: false } }
2301
+ });
2302
+ v.board?.update?.();
2303
+ } catch {
2304
+ }
2305
+ }, [showAxis, showGrid]);
2306
+ const handleView3DReady = React2__namespace.useCallback((view) => {
2307
+ rendererRef.current = new JxgRenderer(scene, view);
2308
+ onReadyChange?.(true);
2309
+ }, [onReadyChange, scene]);
2310
+ const handleClick = React2__namespace.useCallback((screen) => {
2311
+ const board = boardRef.current;
2312
+ if (!board) return;
2313
+ const view = board.getView3D();
2314
+ if (!view) return;
2315
+ try {
2316
+ const hit = hitTest(screen, view, scene);
2317
+ controllerRef.current.consumeHit(hit);
2318
+ } catch {
2319
+ }
2320
+ }, [scene]);
2321
+ const handleMove = React2__namespace.useCallback((screen) => {
2322
+ const board = boardRef.current;
2323
+ if (!board) return;
2324
+ const view = board.getView3D();
2325
+ if (!view) return;
2326
+ if (draggedPointRef.current) return;
2327
+ let hit;
2328
+ try {
2329
+ hit = hitTest(screen, view, scene);
2330
+ } catch {
2331
+ setHoverLabel(null);
2332
+ return;
2333
+ }
2334
+ if (hit.kind === "empty") setHoverLabel(null);
2335
+ else if (hit.kind === "existingPoint") {
2336
+ const obj = scene.get(hit.pointId);
2337
+ setHoverLabel(obj?.label ?? null);
2338
+ } else if (hit.kind === "onGround") setHoverLabel("m\u1EB7t n\u1EC1n");
2339
+ else if (hit.kind === "onAxis") setHoverLabel(`tr\u1EE5c ${hit.axis.toUpperCase()}`);
2340
+ else if (hit.kind === "onPlane") setHoverLabel(`m\u1EB7t ph\u1EB3ng ${hit.planeId}`);
2341
+ else if (hit.kind === "onSphere") setHoverLabel(`m\u1EB7t c\u1EA7u ${hit.sphereId}`);
2342
+ else setHoverLabel(null);
2343
+ }, [scene]);
2344
+ const shouldStartPointDrag = React2__namespace.useCallback((screen) => {
2345
+ const view = boardRef.current?.getView3D();
2346
+ if (!view) return false;
2347
+ const tool = selectedToolRef.current;
2348
+ if (tool !== "point" && tool !== "move") return false;
2349
+ let hit;
2350
+ try {
2351
+ hit = hitTest(screen, view, scene);
2352
+ } catch {
2353
+ return false;
2354
+ }
2355
+ if (hit.kind === "existingPoint") {
2356
+ const pt = scene.get(hit.pointId);
2357
+ if (!pt || pt.kind !== "point") return false;
2358
+ dragSnapshotRef.current = scene.snapshot();
2359
+ draggedPointRef.current = hit.pointId;
2360
+ dragStartRef.current = {
2361
+ screen,
2362
+ world: constraintToWorld(pt.constraint, scene)
2363
+ };
2364
+ return true;
2365
+ }
2366
+ if (tool === "point" && (hit.kind === "onGround" || hit.kind === "onAxis")) {
2367
+ dragSnapshotRef.current = scene.snapshot();
2368
+ const constraint = hitToConstraint(hit);
2369
+ if (!constraint) {
2370
+ dragSnapshotRef.current = null;
2371
+ return false;
2372
+ }
2373
+ let id = null;
2374
+ scene.withoutHistory(() => {
2375
+ id = scene.addPoint(constraint);
2376
+ });
2377
+ if (!id) {
2378
+ dragSnapshotRef.current = null;
2379
+ return false;
2380
+ }
2381
+ draggedPointRef.current = id;
2382
+ dragStartRef.current = {
2383
+ screen,
2384
+ world: [hit.world[0], hit.world[1], hit.world[2]]
2385
+ };
2386
+ return true;
2387
+ }
2388
+ if (tool === "point") {
2389
+ dragSnapshotRef.current = null;
2390
+ draggedPointRef.current = null;
2391
+ dragStartRef.current = null;
2392
+ return true;
2393
+ }
2394
+ return false;
2395
+ }, [scene]);
2396
+ const onPointerDrag = React2__namespace.useCallback((screen) => {
2397
+ const pointId = draggedPointRef.current;
2398
+ const start = dragStartRef.current;
2399
+ if (!pointId || !start) return;
2400
+ const view = boardRef.current?.getView3D();
2401
+ if (!view) return;
2402
+ const tool = selectedToolRef.current;
2403
+ let nextWorld;
2404
+ if (tool === "point") {
2405
+ const dz = screen.y - start.screen.y;
2406
+ nextWorld = [start.world[0], start.world[1], start.world[2] + dz];
2407
+ } else if (tool === "move") {
2408
+ try {
2409
+ const ray = screenToRay(screen, view);
2410
+ const hit = rayPlane(ray, { point: [0, 0, start.world[2]], normal: [0, 0, 1] });
2411
+ if (!hit) return;
2412
+ nextWorld = [hit.point[0], hit.point[1], start.world[2]];
2413
+ } catch {
2414
+ return;
2415
+ }
2416
+ } else {
2417
+ return;
2418
+ }
2419
+ const obj = scene.get(pointId);
2420
+ if (!obj || obj.kind !== "point") return;
2421
+ const free = { kind: "free", x: nextWorld[0], y: nextWorld[1], z: nextWorld[2] };
2422
+ obj.constraint = free;
2423
+ scene.emitChange(pointId);
2424
+ }, [scene]);
2425
+ const onPointerDragEnd = React2__namespace.useCallback(() => {
2426
+ const snap = dragSnapshotRef.current;
2427
+ dragSnapshotRef.current = null;
2428
+ draggedPointRef.current = null;
2429
+ dragStartRef.current = null;
2430
+ if (snap) {
2431
+ scene.pushUndoCheckpoint(snap);
2432
+ }
2433
+ }, [scene]);
2434
+ React2__namespace.useImperativeHandle(
2435
+ ref,
2436
+ () => ({
2437
+ hasContent: () => scene.list().length > 0,
2438
+ serialize: () => {
2439
+ const view = boardRef.current?.getView3D();
2440
+ const v = view;
2441
+ const azSlider = v?.az_slide ?? v?.az;
2442
+ const elSlider = v?.el_slide ?? v?.el;
2443
+ const azimuth = typeof azSlider?.Value === "function" ? azSlider.Value() : 0;
2444
+ const elevation = typeof elSlider?.Value === "function" ? elSlider.Value() : 0;
2445
+ return sceneToBoard(
2446
+ scene,
2447
+ { azimuth, elevation, bbox3D: [-5, -5, -5, 5, 5, 5] },
2448
+ [-6, -6, 6, 6]
2449
+ );
2450
+ },
2451
+ setTool: (k) => controllerRef.current.selectTool(k),
2452
+ undo: () => scene.undo(),
2453
+ redo: () => scene.redo()
2454
+ }),
2455
+ [scene]
2456
+ );
2457
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2458
+ "div",
2459
+ {
2460
+ "data-testid": "editor-panel-3d",
2461
+ className: [
2462
+ isDark ? "theme--dark " : "",
2463
+ "flex h-full w-full min-w-0 flex-col overflow-hidden bg-white"
2464
+ ].join(""),
2465
+ children: [
2466
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", children: /* @__PURE__ */ jsxRuntime.jsx(
2467
+ MiniBoard3D,
2468
+ {
2469
+ ref: boardRef,
2470
+ isDark,
2471
+ onView3DReady: handleView3DReady,
2472
+ onPointerClick: handleClick,
2473
+ onPointerMove: handleMove,
2474
+ onPointerLeave: () => setHoverLabel(null),
2475
+ shouldStartPointDrag,
2476
+ onPointerDrag,
2477
+ onPointerDragEnd
2478
+ }
2479
+ ) }),
2480
+ /* @__PURE__ */ jsxRuntime.jsx(StatusHint, { hint, hoverLabel })
2481
+ ]
2482
+ }
2483
+ );
2484
+ }
2485
+ );
2486
+ }
2487
+ });
2488
+ function ToolButton(props) {
2489
+ const {
2490
+ toolKey,
2491
+ label,
2492
+ selected,
2493
+ onClick,
2494
+ icon,
2495
+ chordNum,
2496
+ chordActiveGroup,
2497
+ onMouseEnter,
2498
+ onMouseLeave
2499
+ } = props;
2500
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2501
+ "button",
2502
+ {
2503
+ type: "button",
2504
+ "data-tool-key": toolKey,
2505
+ "data-testid": `tool-${toolKey}`,
2506
+ "aria-label": label,
2507
+ "aria-pressed": selected,
2508
+ onClick: () => onClick(toolKey),
2509
+ onMouseEnter,
2510
+ onMouseLeave,
2511
+ className: [
2512
+ "relative flex aspect-square items-center justify-center rounded-md transition",
2513
+ selected ? "bg-emerald-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
2514
+ ].join(" "),
2515
+ children: [
2516
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": true, className: "inline-flex", children: icon ?? null }),
2517
+ chordNum != null && /* @__PURE__ */ jsxRuntime.jsx(
2518
+ "span",
2519
+ {
2520
+ "data-testid": `chord-num-${toolKey}`,
2521
+ className: [
2522
+ "pointer-events-none absolute bottom-0 right-0.5 font-mono text-[9px] leading-none transition",
2523
+ selected ? "text-white/70" : chordActiveGroup ? "text-emerald-700" : "text-slate-300"
2524
+ ].join(" "),
2525
+ children: chordNum
2526
+ }
2527
+ )
2528
+ ]
2529
+ }
2530
+ );
2531
+ }
2532
+ var init_ToolButton = __esm({
2533
+ "src/stamps/geometry-3d/editor/toolPanel/ToolButton.tsx"() {
2534
+ "use client";
2535
+ }
2536
+ });
2537
+ var wrap, dot4, ToolIcons;
2538
+ var init_icons = __esm({
2539
+ "src/stamps/geometry-3d/editor/toolPanel/icons.tsx"() {
2540
+ wrap = (children) => /* @__PURE__ */ jsxRuntime.jsx(
2541
+ "svg",
2542
+ {
2543
+ width: "18",
2544
+ height: "18",
2545
+ viewBox: "0 0 24 24",
2546
+ fill: "none",
2547
+ stroke: "currentColor",
2548
+ strokeWidth: "1.6",
2549
+ strokeLinecap: "round",
2550
+ strokeLinejoin: "round",
2551
+ "aria-hidden": "true",
2552
+ children
2553
+ }
2554
+ );
2555
+ dot4 = (cx, cy, r = 1.4) => /* @__PURE__ */ jsxRuntime.jsx("circle", { cx, cy, r, fill: "currentColor", stroke: "none" });
2556
+ ToolIcons = {
2557
+ move: wrap(
2558
+ /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 4 L5 14 L8 11 L10 16 L13 15 L11 10 L15 10 Z" }) })
2559
+ ),
2560
+ point: wrap(
2561
+ /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "2.4", fill: "currentColor", stroke: "none" }) })
2562
+ ),
2563
+ pointOnObject: wrap(
2564
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2565
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 16 L21 12" }),
2566
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "13.5", r: "2.4", fill: "currentColor", stroke: "none" })
2567
+ ] })
2568
+ ),
2569
+ segment: wrap(
2570
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2571
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "18", x2: "20", y2: "6" }),
2572
+ dot4(4, 18, 1.6),
2573
+ dot4(20, 6, 1.6)
2574
+ ] })
2575
+ ),
2576
+ line: wrap(
2577
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2578
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "3", y1: "18", x2: "21", y2: "6" }),
2579
+ dot4(8, 14.5, 1.4),
2580
+ dot4(16, 9.5, 1.4)
2581
+ ] })
2582
+ ),
2583
+ ray: wrap(
2584
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2585
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "5", y1: "18", x2: "19", y2: "7" }),
2586
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M19 7 L15 6 M19 7 L18 11" }),
2587
+ dot4(5, 18, 1.6)
2588
+ ] })
2589
+ ),
2590
+ vector: wrap(
2591
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2592
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "5", y1: "18", x2: "18", y2: "7" }),
2593
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M18 7 L13 7 M18 7 L18 12" }),
2594
+ dot4(5, 18, 1.6)
2595
+ ] })
2596
+ ),
2597
+ polygon: wrap(
2598
+ /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: /* @__PURE__ */ jsxRuntime.jsx("polygon", { points: "12,4 20,10 17,19 7,19 4,10" }) })
2599
+ ),
2600
+ plane: wrap(
2601
+ /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: /* @__PURE__ */ jsxRuntime.jsx("polygon", { points: "3,9 14,5 21,11 10,15" }) })
2602
+ ),
2603
+ pyramid: wrap(
2604
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2605
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 19 L20 19 L12 4 Z" }),
2606
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 19 L12 16 L20 19" }),
2607
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 4 L12 16", strokeDasharray: "2 2" })
2608
+ ] })
2609
+ ),
2610
+ prism: wrap(
2611
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2612
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L4 19 L14 19 L14 8 Z" }),
2613
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L10 4 L20 4 L14 8" }),
2614
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M14 8 L14 19 L20 15 L20 4" }),
2615
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L14 8" })
2616
+ ] })
2617
+ ),
2618
+ tetrahedron: wrap(
2619
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2620
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 19 L20 19 L12 5 Z" }),
2621
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 19 L15 12 L20 19" }),
2622
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M15 12 L12 5" })
2623
+ ] })
2624
+ ),
2625
+ cube: wrap(
2626
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2627
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L4 19 L14 19 L14 8 Z" }),
2628
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L10 4 L20 4 L14 8" }),
2629
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M14 8 L14 19 L20 15 L20 4" })
2630
+ ] })
2631
+ ),
2632
+ sphere: wrap(
2633
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2634
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "8" }),
2635
+ /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: "12", cy: "12", rx: "8", ry: "3" }),
2636
+ dot4(12, 12, 1.2)
2637
+ ] })
2638
+ ),
2639
+ cylinder: wrap(
2640
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2641
+ /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: "12", cy: "6", rx: "6", ry: "2" }),
2642
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M6 6 L6 18" }),
2643
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M18 6 L18 18" }),
2644
+ /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: "12", cy: "18", rx: "6", ry: "2" })
2645
+ ] })
2646
+ ),
2647
+ cone: wrap(
2648
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2649
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "5", y1: "18", x2: "12", y2: "4" }),
2650
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "19", y1: "18", x2: "12", y2: "4" }),
2651
+ /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: "12", cy: "18", rx: "7", ry: "2" })
2652
+ ] })
2653
+ )
2654
+ };
2655
+ }
2656
+ });
2657
+
2658
+ // src/stamps/geometry-3d/editor/toolPanel/groups.ts
2659
+ function letterForGroup(g) {
2660
+ const idx = GROUP_ORDER.indexOf(g);
2661
+ return idx >= 0 ? String.fromCharCode(A_CODE + idx) : "";
2662
+ }
2663
+ var GROUP_ORDER, GROUP_LABELS, TOOLS_BY_GROUP, SPEC_BY_KEY, TOOLS_FLAT, A_CODE;
2664
+ var init_groups = __esm({
2665
+ "src/stamps/geometry-3d/editor/toolPanel/groups.ts"() {
2666
+ init_spec();
2667
+ GROUP_ORDER = [
2668
+ "basic",
2669
+ "point",
2670
+ "line",
2671
+ "plane",
2672
+ "polyhedron",
2673
+ "curve"
2674
+ ];
2675
+ GROUP_LABELS = {
2676
+ basic: "C\u01A1 b\u1EA3n",
2677
+ point: "\u0110i\u1EC3m",
2678
+ line: "\u0110\u01B0\u1EDDng th\u1EB3ng",
2679
+ plane: "M\u1EB7t ph\u1EB3ng",
2680
+ polyhedron: "Kh\u1ED1i \u0111a di\u1EC7n",
2681
+ curve: "Kh\u1ED1i cong"
2682
+ };
2683
+ TOOLS_BY_GROUP = {
2684
+ basic: ["move"],
2685
+ point: ["point", "pointOnObject"],
2686
+ line: ["segment", "line", "ray", "vector", "polygon"],
2687
+ plane: ["plane"],
2688
+ polyhedron: ["pyramid", "prism", "tetrahedron", "cube"],
2689
+ curve: ["sphere", "cylinder", "cone"]
2690
+ };
2691
+ SPEC_BY_KEY = TOOLS.reduce(
2692
+ (acc, t) => {
2693
+ acc[t.key] = t;
2694
+ return acc;
2695
+ },
2696
+ {}
2697
+ );
2698
+ TOOLS_FLAT = GROUP_ORDER.flatMap(
2699
+ (group) => TOOLS_BY_GROUP[group].map((key) => {
2700
+ const spec = SPEC_BY_KEY[key];
2701
+ return {
2702
+ key,
2703
+ label: spec?.label ?? key,
2704
+ hint: spec?.hintIdle ?? "",
2705
+ group
2706
+ };
2707
+ })
2708
+ );
2709
+ A_CODE = "A".charCodeAt(0);
2710
+ }
2711
+ });
2712
+ function ToolPalette(props) {
2713
+ const { selected, onSelect, chordGroup = null, onHoverTool } = props;
2714
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { "data-testid": "tool-palette", className: "flex flex-col gap-3", children: GROUP_ORDER.map((group) => {
2715
+ const keys = TOOLS_BY_GROUP[group];
2716
+ const isChordActive = chordGroup === group;
2717
+ const dimmed = chordGroup !== null && !isChordActive;
2718
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2719
+ "section",
2720
+ {
2721
+ "data-chord-group": group,
2722
+ "data-chord-active": isChordActive ? "true" : "false",
2723
+ className: [
2724
+ "rounded-md transition",
2725
+ isChordActive ? "bg-emerald-50 ring-1 ring-emerald-400 p-1" : "p-0",
2726
+ dimmed ? "opacity-55" : "opacity-100"
2727
+ ].join(" "),
2728
+ children: [
2729
+ /* @__PURE__ */ jsxRuntime.jsxs("h4", { className: "mb-1.5 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-500", children: [
2730
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: GROUP_LABELS[group] }),
2731
+ /* @__PURE__ */ jsxRuntime.jsx(
2732
+ "span",
2733
+ {
2734
+ "data-testid": `chord-letter-${group}`,
2735
+ className: [
2736
+ "font-mono text-[10px] leading-none transition",
2737
+ isChordActive ? "text-emerald-700 font-bold" : "text-slate-400"
2738
+ ].join(" "),
2739
+ children: letterForGroup(group)
2740
+ }
2741
+ )
2742
+ ] }),
2743
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-4 gap-1", children: keys.map((k, i) => {
2744
+ const tool = TOOLS.find((t) => t.key === k);
2745
+ return /* @__PURE__ */ jsxRuntime.jsx(
2746
+ ToolButton,
2747
+ {
2748
+ toolKey: k,
2749
+ label: tool.label,
2750
+ selected: selected === k,
2751
+ onClick: onSelect,
2752
+ icon: ToolIcons[k],
2753
+ chordNum: i + 1,
2754
+ chordActiveGroup: isChordActive,
2755
+ onMouseEnter: (e) => onHoverTool?.({
2756
+ label: tool.label,
2757
+ hint: tool.hintIdle,
2758
+ x: e.clientX,
2759
+ y: e.clientY
2760
+ }),
2761
+ onMouseLeave: () => onHoverTool?.(null)
2762
+ },
2763
+ k
2764
+ );
2765
+ }) })
2766
+ ]
2767
+ },
2768
+ group
2769
+ );
2770
+ }) });
2771
+ }
2772
+ var init_ToolPalette = __esm({
2773
+ "src/stamps/geometry-3d/editor/toolPanel/ToolPalette.tsx"() {
2774
+ "use client";
2775
+ init_ToolButton();
2776
+ init_icons();
2777
+ init_groups();
2778
+ init_spec();
910
2779
  }
911
2780
  });
912
2781
 
913
- // src/stamps/geometry-3d/editor/tools.ts
914
- function letterForGroup3D(g) {
915
- const idx = GROUP_ORDER_3D.indexOf(g);
916
- return idx >= 0 ? String.fromCharCode(A_CODE_3D + idx) : "";
917
- }
918
- var GROUP_LABELS_3D, GROUP_ORDER_3D, A_CODE_3D, TOOLS_3D;
919
- var init_tools = __esm({
920
- "src/stamps/geometry-3d/editor/tools.ts"() {
921
- GROUP_LABELS_3D = {
922
- view: "Xem",
923
- primitive: "C\u01A1 b\u1EA3n",
924
- solid: "Kh\u1ED1i \u0111a di\u1EC7n",
925
- curved: "Kh\u1ED1i cong",
926
- meta: "Kh\xE1c"
927
- };
928
- GROUP_ORDER_3D = [
929
- "view",
930
- "primitive",
931
- "solid",
932
- "curved",
933
- "meta"
934
- ];
935
- A_CODE_3D = "A".charCodeAt(0);
936
- TOOLS_3D = [
937
- { key: "move", label: "Di chuy\u1EC3n", group: "view", stepsRequired: 0 },
938
- { key: "point", label: "\u0110i\u1EC3m", group: "primitive", stepsRequired: 1, hint: "Nh\u1EADp (x, y, z)" },
939
- { key: "segment", label: "\u0110o\u1EA1n th\u1EB3ng", group: "primitive", stepsRequired: 2 },
940
- { key: "line", label: "\u0110\u01B0\u1EDDng th\u1EB3ng", group: "primitive", stepsRequired: 2 },
941
- { key: "plane", label: "M\u1EB7t ph\u1EB3ng", group: "primitive", stepsRequired: 3 },
942
- { key: "triangle", label: "Tam gi\xE1c", group: "primitive", stepsRequired: 3 },
943
- {
944
- key: "polygon",
945
- label: "\u0110a gi\xE1c",
946
- group: "primitive",
947
- stepsRequired: 3,
948
- hint: "Click tr\u1EDF l\u1EA1i \u0111i\u1EC3m \u0111\u1EA7u \u0111\u1EC3 \u0111\xF3ng"
949
- },
950
- { key: "tetrahedron", label: "T\u1EE9 di\u1EC7n", group: "solid", stepsRequired: 4 },
951
- {
952
- key: "parallelepiped",
953
- label: "H\xECnh h\u1ED9p",
954
- group: "solid",
955
- stepsRequired: 1,
956
- hint: "1 \u0111\u1EC9nh + 3 vector"
957
- },
958
- {
959
- key: "prism",
960
- label: "L\u0103ng tr\u1EE5",
961
- group: "solid",
962
- stepsRequired: 3,
963
- hint: "\u0110a gi\xE1c \u0111\xE1y + chi\u1EC1u cao"
964
- },
965
- {
966
- key: "pyramid",
967
- label: "Ch\xF3p",
968
- group: "solid",
969
- stepsRequired: 4,
970
- hint: "\u0110a gi\xE1c \u0111\xE1y + \u0111\u1EC9nh"
971
- },
972
- { key: "sphere", label: "M\u1EB7t c\u1EA7u", group: "curved", stepsRequired: 1, hint: "T\xE2m + b\xE1n k\xEDnh" },
973
- {
974
- key: "cone",
975
- label: "H\xECnh n\xF3n",
976
- group: "curved",
977
- stepsRequired: 2,
978
- hint: "T\xE2m \u0111\xE1y + b\xE1n k\xEDnh + \u0111\u1EC9nh"
979
- },
980
- {
981
- key: "cylinder",
982
- label: "H\xECnh tr\u1EE5",
983
- group: "curved",
984
- stepsRequired: 1,
985
- hint: "T\xE2m \u0111\xE1y + b\xE1n k\xEDnh + chi\u1EC1u cao"
986
- },
987
- { key: "label", label: "Nh\xE3n", group: "meta", stepsRequired: 1, hint: "G\u1EAFn v\xE0o \u0111i\u1EC3m" }
988
- ];
2782
+ // src/stamps/geometry-3d/editor/algebraPanel/symbolic.ts
2783
+ function symbolicFor(obj, scene) {
2784
+ const n = (id) => scene.get(id)?.label ?? id;
2785
+ switch (obj.kind) {
2786
+ case "point": {
2787
+ const c = obj.constraint;
2788
+ switch (c.kind) {
2789
+ case "free":
2790
+ return "Point";
2791
+ case "onGround":
2792
+ return "Point(xyPlane)";
2793
+ case "onAxis":
2794
+ return `Point(${c.axis}Axis)`;
2795
+ case "onPlane":
2796
+ return `Point(${n(c.planeId)})`;
2797
+ case "onLine":
2798
+ return `Point(${n(c.lineId)})`;
2799
+ case "onPolygon":
2800
+ return `Point(${n(c.polygonId)})`;
2801
+ case "onSphere":
2802
+ return `Point(${n(c.sphereId)})`;
2803
+ }
2804
+ return "Point";
2805
+ }
2806
+ case "segment":
2807
+ return `Segment(${n(obj.p1)}, ${n(obj.p2)})`;
2808
+ case "line":
2809
+ return `Line(${n(obj.p1)}, ${n(obj.p2)})`;
2810
+ case "ray":
2811
+ return `Ray(${n(obj.origin)}, ${n(obj.through)})`;
2812
+ case "vector":
2813
+ return `Vector(${n(obj.from)}, ${n(obj.to)})`;
2814
+ case "polygon":
2815
+ return `Polygon(${obj.vertices.map(n).join(", ")})`;
2816
+ case "plane":
2817
+ return `Plane(${n(obj.p1)}, ${n(obj.p2)}, ${n(obj.p3)})`;
2818
+ case "sphere":
2819
+ return `Sphere(${n(obj.center)}, ${n(obj.surfacePoint)})`;
2820
+ case "polyhedron": {
2821
+ const flavorVn = {
2822
+ pyramid: "Ch\xF3p",
2823
+ prism: "L\u0103ng tr\u1EE5",
2824
+ tetrahedron: "T\u1EE9 di\u1EC7n",
2825
+ cube: "L\u1EADp ph\u01B0\u01A1ng"
2826
+ };
2827
+ return `${flavorVn[obj.flavor]}(${obj.vertices.length} \u0111\u1EC9nh)`;
2828
+ }
2829
+ case "cylinder":
2830
+ return `Cylinder(${n(obj.baseCenter)}, ${n(obj.topCenter)}, r=${obj.radius})`;
2831
+ case "cone":
2832
+ return `Cone(${n(obj.baseCenter)}, ${n(obj.apex)}, r=${obj.radius})`;
2833
+ }
2834
+ }
2835
+ function numericFor(obj, scene) {
2836
+ if (obj.kind === "point") {
2837
+ const w = constraintToWorld(obj.constraint, scene);
2838
+ return `(${round(w[0])}, ${round(w[1])}, ${round(w[2])})`;
2839
+ }
2840
+ return "";
2841
+ }
2842
+ function round(x) {
2843
+ return Math.abs(x) < 1e-9 ? "0" : (Math.round(x * 100) / 100).toString();
2844
+ }
2845
+ var init_symbolic = __esm({
2846
+ "src/stamps/geometry-3d/editor/algebraPanel/symbolic.ts"() {
2847
+ init_constraintMath();
989
2848
  }
990
2849
  });
991
- function ToolButton({ toolKey, label, hint, active, onClick, icon, badge }) {
992
- return /* @__PURE__ */ jsxRuntime.jsxs(
2850
+ function RowMenu(props) {
2851
+ const [open, setOpen] = React2__namespace.useState(false);
2852
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative inline-block", children: [
2853
+ /* @__PURE__ */ jsxRuntime.jsx(
2854
+ "button",
2855
+ {
2856
+ type: "button",
2857
+ "aria-label": "Row menu",
2858
+ onClick: () => setOpen((v) => !v),
2859
+ className: "rounded px-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800",
2860
+ children: "\u22EE"
2861
+ }
2862
+ ),
2863
+ open ? /* @__PURE__ */ jsxRuntime.jsxs(
2864
+ "div",
2865
+ {
2866
+ role: "menu",
2867
+ className: "absolute right-0 z-10 mt-1 w-40 rounded-md border border-zinc-200 bg-white py-1 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900",
2868
+ children: [
2869
+ /* @__PURE__ */ jsxRuntime.jsx(MenuItem, { onClick: () => {
2870
+ setOpen(false);
2871
+ props.onRename();
2872
+ }, children: "\u0110\u1ED5i t\xEAn" }),
2873
+ /* @__PURE__ */ jsxRuntime.jsx(MenuItem, { onClick: () => {
2874
+ setOpen(false);
2875
+ props.onChangeColor();
2876
+ }, children: "\u0110\u1ED5i m\xE0u" }),
2877
+ /* @__PURE__ */ jsxRuntime.jsx(MenuItem, { onClick: () => {
2878
+ setOpen(false);
2879
+ props.onToggleVisibility();
2880
+ }, children: props.visible ? "\u1EA8n" : "Hi\u1EC7n" }),
2881
+ /* @__PURE__ */ jsxRuntime.jsx(MenuItem, { onClick: () => {
2882
+ setOpen(false);
2883
+ props.onDelete();
2884
+ }, className: "text-red-600", children: "Xo\xE1" })
2885
+ ]
2886
+ }
2887
+ ) : null
2888
+ ] });
2889
+ }
2890
+ function MenuItem({ children, onClick, className }) {
2891
+ return /* @__PURE__ */ jsxRuntime.jsx(
993
2892
  "button",
994
2893
  {
995
2894
  type: "button",
996
- title: hint ? `${label} \u2014 ${hint}` : label,
997
- "aria-label": label,
998
- "aria-pressed": active,
2895
+ role: "menuitem",
999
2896
  onClick,
1000
- "data-active": active || void 0,
1001
- "data-tool": toolKey,
1002
- className: [
1003
- "relative flex h-8 items-center justify-center rounded-md transition",
1004
- active ? "bg-blue-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
1005
- ].join(" "),
2897
+ className: `block w-full px-3 py-1 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800 ${className ?? ""}`,
2898
+ children
2899
+ }
2900
+ );
2901
+ }
2902
+ var init_RowMenu = __esm({
2903
+ "src/stamps/geometry-3d/editor/algebraPanel/RowMenu.tsx"() {
2904
+ "use client";
2905
+ }
2906
+ });
2907
+ function AlgebraRow(props) {
2908
+ const { obj, scene, onDelete } = props;
2909
+ const symbolic = symbolicFor(obj, scene);
2910
+ const numeric = numericFor(obj, scene);
2911
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2912
+ "li",
2913
+ {
2914
+ "data-testid": `algebra-row-${obj.id}`,
2915
+ className: "flex items-center gap-2 border-b border-zinc-100 px-3 py-1.5 text-xs dark:border-zinc-800",
1006
2916
  children: [
1007
- icon,
1008
- badge
2917
+ /* @__PURE__ */ jsxRuntime.jsx(
2918
+ "span",
2919
+ {
2920
+ "aria-hidden": true,
2921
+ className: "inline-block size-3 rounded-full border",
2922
+ style: { backgroundColor: obj.color ?? "#0066cc" }
2923
+ }
2924
+ ),
2925
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "min-w-[3ch] font-semibold", children: obj.label }),
2926
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-zinc-500", children: "=" }),
2927
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex-1 truncate font-mono", children: symbolic }),
2928
+ numeric ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate text-zinc-500", children: numeric }) : null,
2929
+ /* @__PURE__ */ jsxRuntime.jsx(
2930
+ RowMenu,
2931
+ {
2932
+ visible: obj.visible,
2933
+ onRename: () => {
2934
+ },
2935
+ onChangeColor: () => {
2936
+ },
2937
+ onToggleVisibility: () => {
2938
+ },
2939
+ onDelete: () => onDelete(obj.id)
2940
+ }
2941
+ )
1009
2942
  ]
1010
2943
  }
1011
2944
  );
1012
2945
  }
1013
- var stroke, ICONS_3D;
1014
- var init_toolButtons = __esm({
1015
- "src/stamps/geometry-3d/editor/toolButtons.tsx"() {
2946
+ var init_AlgebraRow = __esm({
2947
+ "src/stamps/geometry-3d/editor/algebraPanel/AlgebraRow.tsx"() {
1016
2948
  "use client";
1017
- stroke = {
1018
- fill: "none",
1019
- stroke: "currentColor",
1020
- strokeWidth: 1.5,
1021
- strokeLinecap: "round",
1022
- strokeLinejoin: "round"
1023
- };
1024
- ICONS_3D = {
1025
- move: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 9l-3 3 3 3M19 9l3 3-3 3M9 5l3-3 3 3M9 19l3 3 3-3" }) }),
1026
- point: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "3", fill: "currentColor" }) }),
1027
- segment: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: [
1028
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "20", x2: "20", y2: "4" }),
1029
- /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4", cy: "20", r: "1.5", fill: "currentColor", stroke: "none" }),
1030
- /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "20", cy: "4", r: "1.5", fill: "currentColor", stroke: "none" })
1031
- ] }),
1032
- line: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "2", y1: "22", x2: "22", y2: "2" }) }),
1033
- plane: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 18 L8 8 L21 6 L16 18 Z" }) }),
1034
- triangle: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 4 L21 20 L3 20 Z" }) }),
1035
- polygon: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L20 9 L17 19 L7 19 L4 9 Z" }) }),
1036
- tetrahedron: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L20 20 L4 20 Z M12 3 L12 20" }) }),
1037
- parallelepiped: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 7 L14 4 L20 7 L14 10 Z M4 7 L4 17 L14 20 L14 10 M14 20 L20 17 L20 7" }) }),
1038
- prism: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 4 L18 8 L18 20 L12 16 Z M12 4 L6 8 L6 20 L12 16 M6 8 L12 12 L18 8 M6 20 L18 20" }) }),
1039
- pyramid: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L4 20 L20 20 Z M12 3 L12 20" }) }),
1040
- sphere: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: [
1041
- /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "8" }),
1042
- /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: "12", cy: "12", rx: "8", ry: "3" })
1043
- ] }),
1044
- cone: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: [
1045
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L4 20 L20 20 Z" }),
1046
- /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: "12", cy: "20", rx: "8", ry: "2" })
1047
- ] }),
1048
- cylinder: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: [
1049
- /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: "12", cy: "5", rx: "6", ry: "2" }),
1050
- /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: "12", cy: "19", rx: "6", ry: "2" }),
1051
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "5", x2: "6", y2: "19" }),
1052
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "5", x2: "18", y2: "19" })
1053
- ] }),
1054
- label: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", ...stroke, children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 4 H 16 L 20 8 L 16 12 H 4 Z" }) })
2949
+ init_symbolic();
2950
+ init_RowMenu();
2951
+ }
2952
+ });
2953
+ function AlgebraList(props) {
2954
+ const { scene } = props;
2955
+ const [, forceUpdate] = React2__namespace.useReducer((x) => x + 1, 0);
2956
+ React2__namespace.useEffect(() => {
2957
+ const unsubAdd = scene.on("add", () => forceUpdate());
2958
+ const unsubChange = scene.on("change", () => forceUpdate());
2959
+ const unsubDelete = scene.on("delete", () => forceUpdate());
2960
+ const unsubReset = scene.on("reset", () => forceUpdate());
2961
+ return () => {
2962
+ unsubAdd();
2963
+ unsubChange();
2964
+ unsubDelete();
2965
+ unsubReset();
1055
2966
  };
2967
+ }, [scene]);
2968
+ const objects = scene.list();
2969
+ return /* @__PURE__ */ jsxRuntime.jsx(
2970
+ "ul",
2971
+ {
2972
+ "data-testid": "algebra-list",
2973
+ className: "flex max-h-[calc(100vh-200px)] flex-col overflow-y-auto",
2974
+ children: objects.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("li", { className: "px-3 py-4 text-center text-xs text-zinc-500", children: "Ch\u01B0a c\xF3 \u0111\u1ED1i t\u01B0\u1EE3ng n\xE0o" }) : objects.map((o) => /* @__PURE__ */ jsxRuntime.jsx(AlgebraRow, { obj: o, scene, onDelete: (id) => scene.delete(id) }, o.id))
2975
+ }
2976
+ );
2977
+ }
2978
+ var init_AlgebraList = __esm({
2979
+ "src/stamps/geometry-3d/editor/algebraPanel/AlgebraList.tsx"() {
2980
+ "use client";
2981
+ init_AlgebraRow();
1056
2982
  }
1057
2983
  });
1058
2984
  function MobileToolDrawer({
@@ -1138,6 +3064,7 @@ function MobileToolDrawer({
1138
3064
  disabled: a.disabled,
1139
3065
  "aria-label": a.label,
1140
3066
  title: a.title ?? a.label,
3067
+ "data-testid": a.testId,
1141
3068
  className: "inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent",
1142
3069
  children: a.icon
1143
3070
  },
@@ -1192,13 +3119,48 @@ var init_MobileToolDrawer = __esm({
1192
3119
  "use client";
1193
3120
  }
1194
3121
  });
3122
+ function AxisIcon() {
3123
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
3124
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "20", x2: "20", y2: "20" }),
3125
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "20", x2: "4", y2: "4" }),
3126
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "20", x2: "16", y2: "8" })
3127
+ ] });
3128
+ }
3129
+ function GridIcon() {
3130
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
3131
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L20 4" }),
3132
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 14 L20 10" }),
3133
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 20 L20 16" }),
3134
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L4 20" }),
3135
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 6 L12 18" }),
3136
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M20 4 L20 16" })
3137
+ ] });
3138
+ }
3139
+ function UndoIcon() {
3140
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
3141
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 10 L8 5 L8 8 L15 8 A5 5 0 0 1 20 13 L20 16" }),
3142
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 10 L8 15 L8 12" })
3143
+ ] });
3144
+ }
3145
+ function RedoIcon() {
3146
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
3147
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M21 10 L16 5 L16 8 L9 8 A5 5 0 0 0 4 13 L4 16" }),
3148
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M21 10 L16 15 L16 12" })
3149
+ ] });
3150
+ }
3151
+ function CloseIcon() {
3152
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
3153
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
3154
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
3155
+ ] });
3156
+ }
1195
3157
  function Shell({ title, icon, onClose, children, isDark }) {
1196
3158
  return /* @__PURE__ */ jsxRuntime.jsxs(
1197
3159
  "aside",
1198
3160
  {
1199
3161
  role: "complementary",
1200
3162
  "aria-label": title,
1201
- "data-testid": "geom3d-left-panel",
3163
+ "data-testid": "left-panel",
1202
3164
  "data-stamp-area": "true",
1203
3165
  className: [
1204
3166
  isDark ? "theme--dark " : "",
@@ -1220,7 +3182,7 @@ function Shell({ title, icon, onClose, children, isDark }) {
1220
3182
  }
1221
3183
  )
1222
3184
  ] }),
1223
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children })
3185
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-3", children })
1224
3186
  ]
1225
3187
  }
1226
3188
  );
@@ -1231,56 +3193,21 @@ function Section({ label, children }) {
1231
3193
  children
1232
3194
  ] });
1233
3195
  }
1234
- function CloseIcon() {
1235
- return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1236
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
1237
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
1238
- ] });
1239
- }
1240
- function UndoIcon() {
1241
- return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1242
- /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "3 7 3 13 9 13" }),
1243
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
1244
- ] });
1245
- }
1246
- function ResetViewIcon() {
1247
- return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1248
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
1249
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 3v5h5" })
1250
- ] });
1251
- }
1252
- function AxisIcon3D() {
1253
- return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
1254
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", y1: "20", x2: "12", y2: "4" }),
1255
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", y1: "12", x2: "22", y2: "6" }),
1256
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", y1: "12", x2: "2", y2: "18" })
1257
- ] });
1258
- }
1259
- function MeshIcon() {
1260
- return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
1261
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L12 4 L20 8 L12 12 Z" }),
1262
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L4 16 L12 20 L12 12" }),
1263
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 20 L20 16 L20 8" })
1264
- ] });
1265
- }
1266
3196
  function useToolHoverTooltip() {
1267
- const [hover, setHover] = react.useState(null);
1268
- const [portalReady, setPortalReady] = react.useState(false);
1269
- const hoverTimerRef = react.useRef(null);
1270
- react.useEffect(() => {
3197
+ const [hover, setHover] = React2__namespace.useState(null);
3198
+ const [portalReady, setPortalReady] = React2__namespace.useState(false);
3199
+ const hoverTimerRef = React2__namespace.useRef(null);
3200
+ React2__namespace.useEffect(() => {
1271
3201
  setPortalReady(true);
1272
3202
  return () => {
1273
3203
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
1274
3204
  };
1275
3205
  }, []);
1276
- const showHover = react.useCallback((el, t) => {
3206
+ const showHover = React2__namespace.useCallback((next) => {
1277
3207
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
1278
- hoverTimerRef.current = setTimeout(() => {
1279
- const r = el.getBoundingClientRect();
1280
- setHover({ label: t.label, hint: t.hint, x: r.right, y: r.top + r.height / 2 });
1281
- }, TOOLTIP_DELAY_MS);
3208
+ hoverTimerRef.current = setTimeout(() => setHover(next), TOOLTIP_DELAY_MS);
1282
3209
  }, []);
1283
- const hideHover = react.useCallback(() => {
3210
+ const hideHover = React2__namespace.useCallback(() => {
1284
3211
  if (hoverTimerRef.current) {
1285
3212
  clearTimeout(hoverTimerRef.current);
1286
3213
  hoverTimerRef.current = null;
@@ -1289,179 +3216,111 @@ function useToolHoverTooltip() {
1289
3216
  }, []);
1290
3217
  return { hover, portalReady, showHover, hideHover };
1291
3218
  }
1292
- function useHandleState(handle) {
1293
- const [tool, setTool] = react.useState("move");
1294
- const [showAxes, setShowAxes] = react.useState(true);
1295
- const [showMesh, setShowMesh] = react.useState(false);
1296
- const [canUndo, setCanUndo] = react.useState(false);
1297
- react.useEffect(() => {
1298
- if (!handle) return;
1299
- const sync = () => {
1300
- setTool(handle.getTool());
1301
- setShowAxes(handle.getShowAxes());
1302
- setShowMesh(handle.getShowMesh());
1303
- setCanUndo(handle.canUndo());
1304
- };
1305
- sync();
1306
- return handle.subscribe(sync);
1307
- }, [handle]);
1308
- return { tool, showAxes, showMesh, canUndo };
1309
- }
1310
3219
  function DesktopPanel(props) {
1311
- const { handle, onResetView, onClose, isDark, chordGroup } = props;
1312
- const { tool, showAxes, showMesh, canUndo } = useHandleState(handle);
3220
+ const {
3221
+ scene,
3222
+ selectedTool,
3223
+ onSelectTool,
3224
+ showAxis,
3225
+ showGrid,
3226
+ onShowAxisChange,
3227
+ onShowGridChange,
3228
+ onUndo,
3229
+ canUndo,
3230
+ onRedo,
3231
+ canRedo,
3232
+ onClose,
3233
+ isDark,
3234
+ chordGroup
3235
+ } = props;
3236
+ const [tab, setTab] = React2__namespace.useState("tools");
1313
3237
  const { hover, portalReady, showHover, hideHover } = useToolHoverTooltip();
1314
- const grouped = react.useMemo(() => {
1315
- return TOOLS_3D.reduce(
1316
- (acc, t) => {
1317
- var _a;
1318
- (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
1319
- return acc;
1320
- },
1321
- {}
1322
- );
1323
- }, []);
1324
- const orderedGroups = react.useMemo(
1325
- () => GROUP_ORDER_3D.filter((g) => grouped[g]),
1326
- [grouped]
1327
- );
1328
- const activeGroupTools = chordGroup ? grouped[chordGroup] ?? null : null;
1329
3238
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1330
3239
  /* @__PURE__ */ jsxRuntime.jsxs(Shell, { title: "H\xECnh h\u1ECDc 3D", icon: Geom3DIconHeader, onClose, isDark, children: [
1331
- /* @__PURE__ */ jsxRuntime.jsx(Section, { label: "B\u1ED1 c\u1EE5c", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 flex-wrap text-[11px] text-slate-700", children: [
1332
- /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "inline-flex select-none items-center gap-1.5", children: [
1333
- /* @__PURE__ */ jsxRuntime.jsx(
1334
- "input",
1335
- {
1336
- type: "checkbox",
1337
- checked: showAxes,
1338
- onChange: (e) => handle?.setShowAxes(e.target.checked),
1339
- "data-testid": "toggle-axes"
1340
- }
1341
- ),
1342
- "Tr\u1EE5c"
1343
- ] }),
1344
- /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "inline-flex select-none items-center gap-1.5", children: [
1345
- /* @__PURE__ */ jsxRuntime.jsx(
1346
- "input",
1347
- {
1348
- type: "checkbox",
1349
- checked: showMesh,
1350
- onChange: (e) => handle?.setShowMesh(e.target.checked),
1351
- "data-testid": "toggle-mesh"
1352
- }
1353
- ),
1354
- "L\u01B0\u1EDBi"
1355
- ] }),
3240
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-1 rounded-md bg-slate-100 p-0.5", children: [
3241
+ /* @__PURE__ */ jsxRuntime.jsx(TabPill, { active: tab === "tools", onClick: () => setTab("tools"), testId: "tab-tools", children: "\u{1F9F0} C\xF4ng c\u1EE5" }),
3242
+ /* @__PURE__ */ jsxRuntime.jsx(TabPill, { active: tab === "algebra", onClick: () => setTab("algebra"), testId: "tab-algebra", children: "\u{1F4D0} \u0110\u1ED1i t\u01B0\u1EE3ng" })
3243
+ ] }),
3244
+ tab === "tools" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3245
+ /* @__PURE__ */ jsxRuntime.jsx(Section, { label: "G\xF3c nh\xECn", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3 text-[11px] text-slate-700", children: [
3246
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "inline-flex select-none items-center gap-1.5", children: [
3247
+ /* @__PURE__ */ jsxRuntime.jsx(
3248
+ "input",
3249
+ {
3250
+ type: "checkbox",
3251
+ checked: showAxis,
3252
+ onChange: (e) => onShowAxisChange(e.target.checked),
3253
+ "data-testid": "toggle-axis"
3254
+ }
3255
+ ),
3256
+ "Tr\u1EE5c"
3257
+ ] }),
3258
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "inline-flex select-none items-center gap-1.5", children: [
3259
+ /* @__PURE__ */ jsxRuntime.jsx(
3260
+ "input",
3261
+ {
3262
+ type: "checkbox",
3263
+ checked: showGrid,
3264
+ onChange: (e) => onShowGridChange(e.target.checked),
3265
+ "data-testid": "toggle-grid"
3266
+ }
3267
+ ),
3268
+ "L\u01B0\u1EDBi"
3269
+ ] }),
3270
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "ml-auto flex items-center gap-0.5", children: [
3271
+ /* @__PURE__ */ jsxRuntime.jsx(
3272
+ "button",
3273
+ {
3274
+ type: "button",
3275
+ onClick: onUndo,
3276
+ disabled: !canUndo,
3277
+ title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
3278
+ "aria-label": "Ho\xE0n t\xE1c",
3279
+ "data-testid": "undo-btn",
3280
+ className: "inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent",
3281
+ children: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon, {})
3282
+ }
3283
+ ),
3284
+ /* @__PURE__ */ jsxRuntime.jsx(
3285
+ "button",
3286
+ {
3287
+ type: "button",
3288
+ onClick: onRedo,
3289
+ disabled: !canRedo,
3290
+ title: "L\xE0m l\u1EA1i (Ctrl/Cmd+Shift+Z)",
3291
+ "aria-label": "L\xE0m l\u1EA1i",
3292
+ "data-testid": "redo-btn",
3293
+ className: "inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent",
3294
+ children: /* @__PURE__ */ jsxRuntime.jsx(RedoIcon, {})
3295
+ }
3296
+ )
3297
+ ] })
3298
+ ] }) }),
1356
3299
  /* @__PURE__ */ jsxRuntime.jsx(
1357
- "button",
3300
+ ToolPalette,
1358
3301
  {
1359
- type: "button",
1360
- onClick: onResetView,
1361
- title: "Reset g\xF3c nh\xECn",
1362
- "aria-label": "Reset view",
1363
- className: "ml-auto inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900",
1364
- children: /* @__PURE__ */ jsxRuntime.jsx(ResetViewIcon, {})
3302
+ selected: selectedTool,
3303
+ onSelect: onSelectTool,
3304
+ chordGroup: chordGroup ?? null,
3305
+ onHoverTool: (info) => info ? showHover(info) : hideHover()
1365
3306
  }
1366
3307
  ),
1367
- /* @__PURE__ */ jsxRuntime.jsx(
1368
- "button",
1369
- {
1370
- type: "button",
1371
- onClick: () => handle?.undo(),
1372
- disabled: !canUndo,
1373
- title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
1374
- "aria-label": "Ho\xE0n t\xE1c",
1375
- className: "inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent",
1376
- children: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon, {})
1377
- }
1378
- )
1379
- ] }) }),
1380
- orderedGroups.map((group) => {
1381
- const tools = grouped[group];
1382
- const isChordActive = chordGroup === group;
1383
- const dimmed = chordGroup !== null && !isChordActive;
1384
- return /* @__PURE__ */ jsxRuntime.jsxs(
1385
- "section",
3308
+ chordGroup && /* @__PURE__ */ jsxRuntime.jsxs(
3309
+ "div",
1386
3310
  {
1387
- "data-chord-group": group,
1388
- "data-chord-active": isChordActive ? "true" : "false",
1389
- className: [
1390
- "rounded-md transition",
1391
- isChordActive ? "bg-blue-50 ring-1 ring-blue-400 p-1" : "p-0",
1392
- dimmed ? "opacity-55" : "opacity-100"
1393
- ].join(" "),
3311
+ "data-testid": "chord-hint",
3312
+ className: "rounded border border-emerald-200 bg-emerald-50/60 px-2 py-1 text-[11px] leading-snug text-slate-600",
1394
3313
  children: [
1395
- /* @__PURE__ */ jsxRuntime.jsxs("h4", { className: "mb-1.5 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-500", children: [
1396
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: GROUP_LABELS_3D[group] }),
1397
- /* @__PURE__ */ jsxRuntime.jsx(
1398
- "span",
1399
- {
1400
- "data-testid": `chord-letter-${group}`,
1401
- className: [
1402
- "font-mono text-[10px] leading-none transition",
1403
- isChordActive ? "text-blue-700 font-bold" : "text-slate-400"
1404
- ].join(" "),
1405
- children: letterForGroup3D(group)
1406
- }
1407
- )
1408
- ] }),
1409
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-4 gap-1", children: tools.map((t, i) => {
1410
- const isActive = tool === t.key;
1411
- return /* @__PURE__ */ jsxRuntime.jsx(
1412
- ToolButton,
1413
- {
1414
- toolKey: t.key,
1415
- label: t.label,
1416
- hint: t.hint,
1417
- active: isActive,
1418
- onClick: () => handle?.setTool(t.key),
1419
- icon: /* @__PURE__ */ jsxRuntime.jsx(
1420
- "span",
1421
- {
1422
- onMouseEnter: (e) => showHover(e.currentTarget.closest("button"), t),
1423
- onMouseLeave: hideHover,
1424
- onFocus: (e) => showHover(e.currentTarget.closest("button"), t),
1425
- onBlur: hideHover,
1426
- children: ICONS_3D[t.key]
1427
- }
1428
- ),
1429
- badge: /* @__PURE__ */ jsxRuntime.jsx(
1430
- "span",
1431
- {
1432
- "data-testid": `chord-num-${t.key}`,
1433
- className: [
1434
- "pointer-events-none absolute bottom-0 right-0.5 font-mono text-[9px] leading-none transition",
1435
- isActive ? "text-white/70" : isChordActive ? "text-blue-700 font-bold" : "text-slate-400"
1436
- ].join(" "),
1437
- children: i + 1
1438
- }
1439
- )
1440
- },
1441
- t.key
1442
- );
1443
- }) })
3314
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono font-semibold text-emerald-700", children: letterForGroup(chordGroup) }),
3315
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "ml-1.5", children: [
3316
+ "\u2192 ",
3317
+ GROUP_LABELS[chordGroup],
3318
+ ". B\u1EA5m s\u1ED1 1-9 \u0111\u1EC3 ch\u1ECDn c\xF4ng c\u1EE5, Esc hu\u1EF7."
3319
+ ] })
1444
3320
  ]
1445
- },
1446
- group
1447
- );
1448
- }),
1449
- chordGroup && activeGroupTools && /* @__PURE__ */ jsxRuntime.jsxs(
1450
- "div",
1451
- {
1452
- "data-testid": "chord-hint",
1453
- className: "mt-1 rounded border border-blue-200 bg-blue-50/60 px-2 py-1 text-[11px] leading-snug text-slate-600",
1454
- children: [
1455
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono font-semibold text-blue-700", children: letterForGroup3D(chordGroup) }),
1456
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "mx-1 text-slate-400", children: "\u2192" }),
1457
- activeGroupTools.map((t, i) => /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "mr-2 inline-block", children: [
1458
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono font-semibold text-blue-700", children: i + 1 }),
1459
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ml-1", children: t.label })
1460
- ] }, t.key)),
1461
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-slate-400", children: "Esc hu\u1EF7" })
1462
- ]
1463
- }
1464
- )
3321
+ }
3322
+ )
3323
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("section", { "data-testid": "algebra-panel", children: /* @__PURE__ */ jsxRuntime.jsx(AlgebraList, { scene }) })
1465
3324
  ] }),
1466
3325
  portalReady && hover && typeof document !== "undefined" ? reactDom.createPortal(
1467
3326
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -1485,64 +3344,103 @@ function DesktopPanel(props) {
1485
3344
  ) : null
1486
3345
  ] });
1487
3346
  }
1488
- function MobilePanel(props) {
1489
- const { handle, onResetView, isDark, drawerOpen, onDrawerClose } = props;
1490
- const { tool, showAxes, showMesh, canUndo } = useHandleState(handle);
1491
- const groups = react.useMemo(() => {
1492
- const acc = /* @__PURE__ */ new Map();
1493
- for (const t of TOOLS_3D) {
1494
- if (!acc.has(t.group)) acc.set(t.group, []);
1495
- acc.get(t.group).push(t);
3347
+ function TabPill({
3348
+ active,
3349
+ onClick,
3350
+ testId,
3351
+ children
3352
+ }) {
3353
+ return /* @__PURE__ */ jsxRuntime.jsx(
3354
+ "button",
3355
+ {
3356
+ type: "button",
3357
+ onClick,
3358
+ "aria-pressed": active,
3359
+ "data-testid": testId,
3360
+ className: [
3361
+ "flex-1 rounded px-2 py-1 text-[11px] font-medium transition",
3362
+ active ? "bg-white text-slate-900 shadow-sm ring-1 ring-slate-200" : "text-slate-500 hover:text-slate-800"
3363
+ ].join(" "),
3364
+ children
1496
3365
  }
1497
- return Array.from(acc.entries()).map(([group, tools]) => ({
1498
- group,
1499
- groupLabel: GROUP_LABELS_3D[group],
1500
- tools: tools.map((t) => ({ key: t.key, label: t.label, icon: ICONS_3D[t.key] }))
1501
- }));
1502
- }, []);
3366
+ );
3367
+ }
3368
+ function MobilePanel(props) {
3369
+ const {
3370
+ selectedTool,
3371
+ onSelectTool,
3372
+ showAxis,
3373
+ showGrid,
3374
+ onShowAxisChange,
3375
+ onShowGridChange,
3376
+ onUndo,
3377
+ canUndo,
3378
+ onRedo,
3379
+ canRedo,
3380
+ isDark,
3381
+ drawerOpen,
3382
+ onDrawerClose
3383
+ } = props;
3384
+ const groups = React2__namespace.useMemo(
3385
+ () => GROUP_ORDER.map((group) => {
3386
+ const keys = TOOLS_BY_GROUP[group];
3387
+ return {
3388
+ group,
3389
+ groupLabel: GROUP_LABELS[group],
3390
+ tools: keys.map((k) => {
3391
+ const tool = TOOLS.find((t) => t.key === k);
3392
+ return { key: k, label: tool.label, icon: ToolIcons[k] };
3393
+ })
3394
+ };
3395
+ }),
3396
+ []
3397
+ );
1503
3398
  return /* @__PURE__ */ jsxRuntime.jsx(
1504
3399
  MobileToolDrawer,
1505
3400
  {
1506
3401
  title: "H\xECnh h\u1ECDc 3D",
1507
3402
  headerIcon: Geom3DIconHeader,
1508
- testId: "geom3d-left-panel",
3403
+ testId: "left-panel",
1509
3404
  isDark,
1510
3405
  drawerOpen: !!drawerOpen,
1511
3406
  onDrawerClose: () => onDrawerClose?.(),
1512
3407
  chips: [
1513
3408
  {
1514
3409
  label: "Tr\u1EE5c",
1515
- icon: /* @__PURE__ */ jsxRuntime.jsx(AxisIcon3D, {}),
1516
- pressed: showAxes,
1517
- onToggle: (b) => handle?.setShowAxes(b),
1518
- testId: "toggle-axes"
3410
+ icon: /* @__PURE__ */ jsxRuntime.jsx(AxisIcon, {}),
3411
+ pressed: showAxis,
3412
+ onToggle: onShowAxisChange,
3413
+ testId: "toggle-axis"
1519
3414
  },
1520
3415
  {
1521
3416
  label: "L\u01B0\u1EDBi",
1522
- icon: /* @__PURE__ */ jsxRuntime.jsx(MeshIcon, {}),
1523
- pressed: showMesh,
1524
- onToggle: (b) => handle?.setShowMesh(b),
1525
- testId: "toggle-mesh"
3417
+ icon: /* @__PURE__ */ jsxRuntime.jsx(GridIcon, {}),
3418
+ pressed: showGrid,
3419
+ onToggle: onShowGridChange,
3420
+ testId: "toggle-grid"
1526
3421
  }
1527
3422
  ],
1528
3423
  actions: [
1529
- {
1530
- label: "Reset view",
1531
- title: "Reset g\xF3c nh\xECn",
1532
- icon: /* @__PURE__ */ jsxRuntime.jsx(ResetViewIcon, {}),
1533
- onClick: onResetView
1534
- },
1535
3424
  {
1536
3425
  label: "Ho\xE0n t\xE1c",
1537
3426
  title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
1538
3427
  icon: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon, {}),
1539
- onClick: () => handle?.undo(),
1540
- disabled: !canUndo
3428
+ onClick: onUndo,
3429
+ disabled: !canUndo,
3430
+ testId: "undo-btn"
3431
+ },
3432
+ {
3433
+ label: "L\xE0m l\u1EA1i",
3434
+ title: "L\xE0m l\u1EA1i (Ctrl/Cmd+Shift+Z)",
3435
+ icon: /* @__PURE__ */ jsxRuntime.jsx(RedoIcon, {}),
3436
+ onClick: onRedo,
3437
+ disabled: !canRedo,
3438
+ testId: "redo-btn"
1541
3439
  }
1542
3440
  ],
1543
3441
  groups,
1544
- activeTool: tool,
1545
- onToolSelect: (k) => handle?.setTool(k)
3442
+ activeTool: selectedTool,
3443
+ onToolSelect: onSelectTool
1546
3444
  }
1547
3445
  );
1548
3446
  }
@@ -1554,11 +3452,18 @@ var TOOLTIP_DELAY_MS, Geom3DIconHeader;
1554
3452
  var init_LeftPanel = __esm({
1555
3453
  "src/stamps/geometry-3d/editor/LeftPanel.tsx"() {
1556
3454
  "use client";
1557
- init_tools();
1558
- init_toolButtons();
3455
+ init_ToolPalette();
3456
+ init_AlgebraList();
3457
+ init_icons();
3458
+ init_groups();
3459
+ init_spec();
1559
3460
  init_MobileToolDrawer();
1560
3461
  TOOLTIP_DELAY_MS = 400;
1561
- Geom3DIconHeader = /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 7 L14 4 L20 7 L14 10 Z M4 7 L4 17 L14 20 L14 10 M14 20 L20 17 L20 7" }) });
3462
+ Geom3DIconHeader = /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
3463
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 9 L4 20 L14 20 L14 9 Z" }),
3464
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 9 L10 4 L20 4 L14 9 Z" }),
3465
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M14 9 L20 4 L20 15 L14 20 Z" })
3466
+ ] });
1562
3467
  }
1563
3468
  });
1564
3469
  function isFieldFocused() {
@@ -1567,19 +3472,19 @@ function isFieldFocused() {
1567
3472
  }
1568
3473
  function useChordShortcut(args) {
1569
3474
  const { groupOrder, tools, onSelect, enabled } = args;
1570
- const [chordGroup, setChordGroup] = react.useState(null);
1571
- const groupOrderRef = react.useRef(groupOrder);
1572
- const toolsRef = react.useRef(tools);
1573
- const onSelectRef = react.useRef(onSelect);
1574
- const chordGroupRef = react.useRef(null);
3475
+ const [chordGroup, setChordGroup] = React2.useState(null);
3476
+ const groupOrderRef = React2.useRef(groupOrder);
3477
+ const toolsRef = React2.useRef(tools);
3478
+ const onSelectRef = React2.useRef(onSelect);
3479
+ const chordGroupRef = React2.useRef(null);
1575
3480
  groupOrderRef.current = groupOrder;
1576
3481
  toolsRef.current = tools;
1577
3482
  onSelectRef.current = onSelect;
1578
- const cancel = react.useCallback(() => {
3483
+ const cancel = React2.useCallback(() => {
1579
3484
  chordGroupRef.current = null;
1580
3485
  setChordGroup(null);
1581
3486
  }, []);
1582
- react.useEffect(() => {
3487
+ React2.useEffect(() => {
1583
3488
  if (!enabled) return;
1584
3489
  const setChord = (next) => {
1585
3490
  chordGroupRef.current = next;
@@ -1599,7 +3504,7 @@ function useChordShortcut(args) {
1599
3504
  return;
1600
3505
  }
1601
3506
  if (lower.length === 1 && lower >= "a" && lower <= "z") {
1602
- const idx = lower.charCodeAt(0) - A_CODE;
3507
+ const idx = lower.charCodeAt(0) - A_CODE2;
1603
3508
  if (idx < groupOrderRef.current.length) {
1604
3509
  e.preventDefault();
1605
3510
  e.stopPropagation();
@@ -1630,10 +3535,10 @@ function useChordShortcut(args) {
1630
3535
  }, [enabled]);
1631
3536
  return { chordGroup, cancel };
1632
3537
  }
1633
- var A_CODE;
3538
+ var A_CODE2;
1634
3539
  var init_useChordShortcut = __esm({
1635
3540
  "src/stamps/shared/useChordShortcut.ts"() {
1636
- A_CODE = "a".charCodeAt(0);
3541
+ A_CODE2 = "a".charCodeAt(0);
1637
3542
  }
1638
3543
  });
1639
3544
 
@@ -1762,11 +3667,11 @@ function readMatch(query) {
1762
3667
  }
1763
3668
  }
1764
3669
  function useIsMobile() {
1765
- const [state, setState] = react.useState(() => ({
3670
+ const [state, setState] = React2.useState(() => ({
1766
3671
  isMobile: readMatch(MOBILE_QUERY),
1767
3672
  isTouchOnly: readMatch(NO_HOVER_QUERY)
1768
3673
  }));
1769
- react.useEffect(() => {
3674
+ React2.useEffect(() => {
1770
3675
  if (typeof window === "undefined" || !window.matchMedia) return;
1771
3676
  const mql = window.matchMedia(MOBILE_QUERY);
1772
3677
  const tql = window.matchMedia(NO_HOVER_QUERY);
@@ -1812,36 +3717,56 @@ var init_host = __esm({
1812
3717
  "use client";
1813
3718
  init_EditorPanel();
1814
3719
  init_LeftPanel();
1815
- init_tools();
3720
+ init_Scene3D();
3721
+ init_groups();
1816
3722
  init_useChordShortcut();
1817
3723
  init_insertImage();
1818
3724
  init_useIsMobile();
1819
3725
  init_serialize();
1820
- Geometry3DStampHost = react.forwardRef(
3726
+ Geometry3DStampHost = React2.forwardRef(
1821
3727
  function Geometry3DStampHost2({ api, editingElement, onClose, isDark }, ref) {
1822
- const editorRef = react.useRef(null);
3728
+ const editorRef = React2.useRef(null);
3729
+ const sceneRef = React2.useRef(null);
3730
+ if (!sceneRef.current) sceneRef.current = new Scene3D();
1823
3731
  const { isMobile } = useIsMobile();
1824
- const [drawerOpen, setDrawerOpen] = react.useState(false);
1825
- const [boardHandle, setBoardHandle] = react.useState(null);
1826
- const initial = react.useMemo(
3732
+ const [drawerOpen, setDrawerOpen] = React2.useState(false);
3733
+ const [ready, setReady] = React2.useState(false);
3734
+ const [selectedTool, setSelectedTool] = React2.useState("move");
3735
+ const [showAxis, setShowAxis] = React2.useState(true);
3736
+ const [showGrid, setShowGrid] = React2.useState(true);
3737
+ const [canUndo, setCanUndo] = React2.useState(false);
3738
+ const [canRedo, setCanRedo] = React2.useState(false);
3739
+ const handleHistoryChange = React2.useCallback((u, r) => {
3740
+ setCanUndo(u);
3741
+ setCanRedo(r);
3742
+ }, []);
3743
+ const handleUndo = React2.useCallback(() => {
3744
+ editorRef.current?.undo();
3745
+ }, []);
3746
+ const handleRedo = React2.useCallback(() => {
3747
+ editorRef.current?.redo();
3748
+ }, []);
3749
+ const initial = React2.useMemo(
1827
3750
  () => parseInitial(editingElement),
1828
3751
  [editingElement]
1829
3752
  );
1830
- const handleBoardReady = react.useCallback((h) => {
1831
- setBoardHandle((prev) => prev === h ? prev : h);
1832
- }, []);
1833
3753
  const { chordGroup } = useChordShortcut({
1834
- groupOrder: GROUP_ORDER_3D,
1835
- tools: TOOLS_3D,
1836
- onSelect: (key) => boardHandle?.setTool(key),
3754
+ groupOrder: GROUP_ORDER,
3755
+ tools: TOOLS_FLAT,
3756
+ onSelect: (key) => {
3757
+ setSelectedTool(key);
3758
+ editorRef.current?.setTool(key);
3759
+ },
1837
3760
  enabled: !isMobile
1838
3761
  });
1839
- const handleResetView = react.useCallback(() => {
1840
- boardHandle?.resetView();
1841
- }, [boardHandle]);
1842
- const handleInsert = react.useCallback(
1843
- async (jsonState, svgString, width, height) => {
3762
+ const handleSelectTool = React2.useCallback((k) => {
3763
+ setSelectedTool(k);
3764
+ editorRef.current?.setTool(k);
3765
+ }, []);
3766
+ const performInsert = React2.useCallback(
3767
+ async (board, width, height, svgString) => {
1844
3768
  if (!api) return;
3769
+ const jsonState = serializeBoard3D(board);
1845
3770
  await insertStampImage(api, {
1846
3771
  svgString,
1847
3772
  makeCustomData: () => ({
@@ -1857,40 +3782,174 @@ var init_host = __esm({
1857
3782
  },
1858
3783
  [api, editingElement, onClose]
1859
3784
  );
1860
- react.useImperativeHandle(
3785
+ const tryInsert = React2.useCallback(() => {
3786
+ if (!editorRef.current) return false;
3787
+ if (!editorRef.current.hasContent()) return false;
3788
+ const board = editorRef.current.serialize();
3789
+ if (board.elements.length === 0) return false;
3790
+ void performInsert(board, 0, 0, "");
3791
+ return true;
3792
+ }, [performInsert]);
3793
+ React2.useImperativeHandle(
1861
3794
  ref,
1862
3795
  () => ({
1863
- tryInsert: () => editorRef.current?.tryInsert() ?? false,
3796
+ tryInsert,
1864
3797
  hasContent: () => editorRef.current?.hasContent() ?? false
1865
3798
  }),
1866
- []
3799
+ [tryInsert]
3800
+ );
3801
+ const handleEditorInsert = React2.useCallback(
3802
+ (board, width, height, svgString) => {
3803
+ void performInsert(board, width, height, svgString);
3804
+ },
3805
+ [performInsert]
1867
3806
  );
3807
+ const dialogStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
3808
+ position: "absolute",
3809
+ top: "50%",
3810
+ left: "calc(50% + 120px)",
3811
+ transform: "translate(-50%, -50%)",
3812
+ zIndex: 40
3813
+ };
1868
3814
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1869
- /* @__PURE__ */ jsxRuntime.jsx(
3815
+ !isMobile && /* @__PURE__ */ jsxRuntime.jsx(
1870
3816
  LeftPanel,
1871
3817
  {
1872
- handle: boardHandle,
1873
- onResetView: handleResetView,
3818
+ scene: sceneRef.current,
3819
+ selectedTool,
3820
+ onSelectTool: handleSelectTool,
3821
+ showAxis,
3822
+ showGrid,
3823
+ onShowAxisChange: setShowAxis,
3824
+ onShowGridChange: setShowGrid,
3825
+ onUndo: handleUndo,
3826
+ canUndo,
3827
+ onRedo: handleRedo,
3828
+ canRedo,
1874
3829
  onClose,
1875
3830
  isDark,
1876
- isMobile,
1877
- drawerOpen,
1878
- onDrawerClose: () => setDrawerOpen(false),
1879
3831
  chordGroup
1880
3832
  }
1881
3833
  ),
1882
- /* @__PURE__ */ jsxRuntime.jsx(
1883
- EditorPanel,
3834
+ /* @__PURE__ */ jsxRuntime.jsxs(
3835
+ "div",
1884
3836
  {
1885
- ref: editorRef,
1886
- isDark,
1887
- initial,
1888
- onInsert: handleInsert,
3837
+ role: "dialog",
3838
+ "aria-label": "D\u1EF1ng h\xECnh h\u1ECDc 3D",
3839
+ "data-testid": "geom3d-host",
3840
+ "data-stamp-area": "true",
3841
+ style: dialogStyle,
3842
+ className: [
3843
+ isDark ? "theme--dark " : "",
3844
+ "flex flex-col overflow-hidden bg-white",
3845
+ isMobile ? "h-full w-full" : "h-[600px] max-h-[85vh] w-[800px] max-w-[calc(100vw-320px)] rounded-lg border border-slate-300 shadow-2xl ring-1 ring-black/5"
3846
+ ].join(" "),
3847
+ children: [
3848
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center gap-2 border-b border-slate-200 bg-gradient-to-r from-emerald-600 to-teal-600 px-3 py-2 text-white", children: [
3849
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
3850
+ "button",
3851
+ {
3852
+ type: "button",
3853
+ onClick: () => setDrawerOpen(true),
3854
+ "aria-label": "M\u1EDF ng\u0103n c\xF4ng c\u1EE5",
3855
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
3856
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3857
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
3858
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
3859
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
3860
+ ] })
3861
+ }
3862
+ ),
3863
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
3864
+ /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 9 L4 20 L14 20 L14 9 Z M4 9 L10 4 L20 4 L14 9 Z M14 9 L20 4 L20 15 L14 20 Z" }) }),
3865
+ "D\u1EF1ng h\xECnh h\u1ECDc kh\xF4ng gian"
3866
+ ] }),
3867
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
3868
+ "button",
3869
+ {
3870
+ type: "button",
3871
+ onClick: tryInsert,
3872
+ disabled: !ready,
3873
+ "data-testid": "geom3d-insert-btn-mobile",
3874
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
3875
+ children: "Ch\xE8n"
3876
+ }
3877
+ ),
3878
+ /* @__PURE__ */ jsxRuntime.jsx(
3879
+ "button",
3880
+ {
3881
+ onClick: onClose,
3882
+ "aria-label": "\u0110\xF3ng",
3883
+ className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15",
3884
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3885
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
3886
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
3887
+ ] })
3888
+ }
3889
+ )
3890
+ ] }),
3891
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", children: /* @__PURE__ */ jsxRuntime.jsx(
3892
+ EditorPanel,
3893
+ {
3894
+ ref: editorRef,
3895
+ isDark,
3896
+ initialState: initial,
3897
+ onInsert: handleEditorInsert,
3898
+ scene: sceneRef.current,
3899
+ selectedTool,
3900
+ onSelectedToolChange: setSelectedTool,
3901
+ showAxis,
3902
+ showGrid,
3903
+ onReadyChange: setReady,
3904
+ onHistoryChange: handleHistoryChange
3905
+ }
3906
+ ) }),
3907
+ !isMobile && /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
3908
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-slate-500", children: "Ch\u1ECDn c\xF4ng c\u1EE5 b\xEAn tr\xE1i, click tr\xEAn b\u1EA3ng \u0111\u1EC3 d\u1EF1ng h\xECnh." }),
3909
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
3910
+ /* @__PURE__ */ jsxRuntime.jsx(
3911
+ "button",
3912
+ {
3913
+ onClick: onClose,
3914
+ className: "rounded border border-slate-300 bg-white px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-100",
3915
+ children: "Hu\u1EF7"
3916
+ }
3917
+ ),
3918
+ /* @__PURE__ */ jsxRuntime.jsx(
3919
+ "button",
3920
+ {
3921
+ onClick: tryInsert,
3922
+ disabled: !ready,
3923
+ "data-testid": "geom3d-insert-btn",
3924
+ className: "rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white transition hover:bg-emerald-700 disabled:opacity-50",
3925
+ children: "Ch\xE8n"
3926
+ }
3927
+ )
3928
+ ] })
3929
+ ] })
3930
+ ]
3931
+ }
3932
+ ),
3933
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
3934
+ LeftPanel,
3935
+ {
3936
+ scene: sceneRef.current,
3937
+ selectedTool,
3938
+ onSelectTool: handleSelectTool,
3939
+ showAxis,
3940
+ showGrid,
3941
+ onShowAxisChange: setShowAxis,
3942
+ onShowGridChange: setShowGrid,
3943
+ onUndo: handleUndo,
3944
+ canUndo,
3945
+ onRedo: handleRedo,
3946
+ canRedo,
1889
3947
  onClose,
1890
- isMobile,
1891
- withLeftPanel: !isMobile,
1892
- onBoardReady: handleBoardReady,
1893
- onOpenDrawer: () => setDrawerOpen(true)
3948
+ isDark,
3949
+ isMobile: true,
3950
+ drawerOpen,
3951
+ onDrawerClose: () => setDrawerOpen(false),
3952
+ chordGroup
1894
3953
  }
1895
3954
  )
1896
3955
  ] });
@@ -1904,6 +3963,7 @@ init_serialize();
1904
3963
 
1905
3964
  // src/stamps/geometry-3d/render.ts
1906
3965
  init_serialize();
3966
+ init_theme2();
1907
3967
  var OUTPUT_WIDTH = 1024;
1908
3968
  var OUTPUT_HEIGHT = 768;
1909
3969
  async function renderGeometry3DSvgFromState(jsonState) {
@@ -1921,6 +3981,7 @@ async function renderGeometry3DSvgFromState(jsonState) {
1921
3981
  showNavigation: false,
1922
3982
  renderer: "svg"
1923
3983
  });
3984
+ const baseAttrs = VIEW3D_ATTRS(false);
1924
3985
  const view = board.create(
1925
3986
  "view3d",
1926
3987
  [
@@ -1933,14 +3994,28 @@ async function renderGeometry3DSvgFromState(jsonState) {
1933
3994
  ]
1934
3995
  ],
1935
3996
  {
1936
- az: { slider: { visible: false }, value: state.view.azimuth },
1937
- el: { slider: { visible: false }, value: state.view.elevation },
1938
- projection: "central"
3997
+ ...baseAttrs,
3998
+ az: { ...baseAttrs.az, value: state.view.azimuth },
3999
+ el: { ...baseAttrs.el, value: state.view.elevation }
1939
4000
  }
1940
4001
  );
1941
4002
  if (!state.showAxes) {
1942
4003
  view.defaultAxes = [];
1943
4004
  }
4005
+ try {
4006
+ view.create(
4007
+ "plane3d",
4008
+ [
4009
+ [0, 0, 0],
4010
+ [1, 0, 0],
4011
+ [0, 1, 0],
4012
+ GROUND_PLANE_RANGE,
4013
+ GROUND_PLANE_RANGE
4014
+ ],
4015
+ GROUND_PLANE_ATTRS(false)
4016
+ );
4017
+ } catch {
4018
+ }
1944
4019
  const idMap = /* @__PURE__ */ new Map();
1945
4020
  for (const el of state.elements) {
1946
4021
  const parents = el.parents.map(
@@ -1970,7 +4045,7 @@ async function renderGeometry3DSvgFromState(jsonState) {
1970
4045
  document.body.removeChild(div);
1971
4046
  }
1972
4047
  }
1973
- var Geometry3DStampHost3 = react.lazy(
4048
+ var Geometry3DStampHost3 = React2.lazy(
1974
4049
  () => Promise.resolve().then(() => (init_host(), host_exports)).then((m) => ({ default: m.Geometry3DStampHost }))
1975
4050
  );
1976
4051
  var Geometry3DIcon = /* @__PURE__ */ jsxRuntime.jsxs(