partforge 0.90.0 → 0.91.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.90.0",
3
+ "version": "0.91.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -387,6 +387,14 @@ export function createCutaway({
387
387
  return !enabled || pointSurvivesPlane(plane, point);
388
388
  }
389
389
 
390
+ // The live clip plane, for a caller that has to reason about the CUT itself
391
+ // rather than test a point against it — clicking the cut face is the only one
392
+ // today. Null while disabled, and a copy either way, so no caller can hold a
393
+ // stale plane or move ours.
394
+ function getPlane(target = new THREE.Plane()) {
395
+ return enabled ? target.copy(plane) : null;
396
+ }
397
+
390
398
  function registerClippableMaterial(material) {
391
399
  if (disposed || !material) return () => {};
392
400
  let entry = auxiliaryMaterials.get(material);
@@ -521,6 +529,7 @@ export function createCutaway({
521
529
  setTheme,
522
530
  setViewportSize,
523
531
  isPointVisible,
532
+ getPlane,
524
533
  registerClippableMaterial,
525
534
  updateForCamera,
526
535
  renderOverlay,
@@ -0,0 +1,44 @@
1
+ // Sizing for the transient pick marker (viewer.flashPoint).
2
+ //
3
+ // The marker used to be a fixed 1.2 mm sphere — a constant WORLD size, so it
4
+ // swelled to cover the very feature it was pointing at as soon as you zoomed
5
+ // in, and shrank to nothing on a large part. Sizing it from the camera each
6
+ // frame keeps it a constant handful of CSS pixels under both projections.
7
+ import * as THREE from "three";
8
+
9
+ export const FLASH_PIXEL_RADIUS = 5; // CSS px — a ~10px dot, the size it read as at default framing
10
+
11
+ const MIN_RADIUS = 1e-6; // never a zero-scale matrix
12
+
13
+ const _offset = new THREE.Vector3();
14
+ const _forward = new THREE.Vector3();
15
+
16
+ // World units spanned by one CSS pixel at `worldPoint`, for `camera`.
17
+ //
18
+ // Perspective: the frustum widens with view depth, so the answer is measured
19
+ // along the view direction rather than as a straight distance to the camera —
20
+ // an off-axis point is farther from the eye than it is deep, and would size a
21
+ // fraction too large. Orthographic: depth does not enter into it at all; the
22
+ // frustum height (as zoom leaves it) is the whole story.
23
+ export function worldPerPixel(camera, worldPoint, viewportHeightPx) {
24
+ const height = viewportHeightPx > 0 ? viewportHeightPx : 1;
25
+ if (camera.isOrthographicCamera) {
26
+ const extent = Math.abs(camera.top - camera.bottom) / (camera.zoom || 1);
27
+ return extent / height;
28
+ }
29
+ camera.getWorldDirection(_forward);
30
+ const depth = Math.abs(_offset.copy(worldPoint).sub(camera.position).dot(_forward));
31
+ const fov = ((camera.fov ?? 50) * Math.PI) / 180;
32
+ return (2 * Math.tan(fov / 2) * depth) / height;
33
+ }
34
+
35
+ // Radius, in world units, for a marker that should read as `pixelRadius` CSS px.
36
+ export function flashWorldRadius(
37
+ camera,
38
+ worldPoint,
39
+ viewportHeightPx,
40
+ pixelRadius = FLASH_PIXEL_RADIUS,
41
+ ) {
42
+ const radius = worldPerPixel(camera, worldPoint, viewportHeightPx) * pixelRadius;
43
+ return Number.isFinite(radius) && radius > MIN_RADIUS ? radius : MIN_RADIUS;
44
+ }
@@ -10,13 +10,18 @@ const fmtParams = (p) => Object.entries(p).map(([k, v]) => `${k}:${v}`).join(","
10
10
  function tokenStyle(s) {
11
11
  const head = `@${s.subPart}`;
12
12
  const feat = s.feature ? ` · ${s.feature.label}` : "";
13
- return `${head}${feat} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
13
+ const cut = s.onCutPlane ? " · section" : "";
14
+ return `${head}${feat}${cut} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
14
15
  }
15
16
 
16
17
  function promptStyle(s) {
17
18
  const params = Object.entries(s.params).map(([k, v]) => `${k}: ${v}`).join(", ");
18
19
  const feat = s.feature ? ` **${s.feature.label}**,` : "";
19
- return `On sub-part **${s.subPart}**, the user pointed at${feat} local point (${s.point.join(", ")}), `
20
+ // The section wording is load-bearing: without it the point reads as a spot
21
+ // on the part's surface, when it is a spot inside the material that the
22
+ // cutaway happens to have opened up.
23
+ const cut = s.onCutPlane ? " the cutaway section face at" : "";
24
+ return `On sub-part **${s.subPart}**, the user pointed at${feat}${cut} local point (${s.point.join(", ")}), `
20
25
  + `normal ${fmtNormal(s.normal)}, with params {${params}}.`;
21
26
  }
22
27
 
@@ -19,7 +19,9 @@ export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
19
19
  // a suppressed click must still clear its just-dragged flag.
20
20
  const wasDragged = drag.consumeClick();
21
21
  if (!active || wasDragged || suppressed?.()) return;
22
- const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
22
+ // includeSection: in a cutaway the flat cut face is the biggest thing on
23
+ // screen, and it is the one surface with no geometry behind it to hit.
24
+ const hit = raycastViewer(viewer, ev.clientX, ev.clientY, { includeSection: true });
23
25
  if (!hit) return;
24
26
  const selection = resolveSelection(part, getContext(), hit);
25
27
  viewer.flashPoint([hit.pointWorld.x, hit.pointWorld.y, hit.pointWorld.z]);
@@ -26,6 +26,18 @@ function normalInSubPartFrame(mesh, normal) {
26
26
  return [_normal.x, _normal.y, _normal.z];
27
27
  }
28
28
 
29
+ const _inverseWorld = new THREE.Matrix4();
30
+
31
+ // The same trip for a WORLD direction: back through the mesh's world transform
32
+ // into the geometry frame, then forward by its local matrix like any other
33
+ // geometry-frame direction. The cut plane's normal is the only one that arrives
34
+ // this way — it belongs to the scene, not to a triangle.
35
+ function worldNormalInSubPartFrame(mesh, worldNormal) {
36
+ _inverseWorld.copy(mesh.matrixWorld).invert();
37
+ _normal.copy(worldNormal).transformDirection(_inverseWorld).transformDirection(mesh.matrix);
38
+ return [_normal.x, _normal.y, _normal.z];
39
+ }
40
+
29
41
  // The feature carried by a mesh triangle, or null (unlabeled / no attribution data).
30
42
  export function featureAt(mesh, triIndex) {
31
43
  const { featureIds, features } = mesh.geometry.userData;
@@ -33,7 +45,84 @@ export function featureAt(mesh, triIndex) {
33
45
  return id > 0 ? { id, label: features[id - 1] } : null;
34
46
  }
35
47
 
36
- export function raycastViewer(viewer, clientX, clientY) {
48
+ const SECTION_EPSILON = 1e-6;
49
+ const _sectionPoint = new THREE.Vector3();
50
+ const _sectionNormal = new THREE.Vector3();
51
+ const _reverseDirection = new THREE.Vector3();
52
+ const _cutPlane = new THREE.Plane();
53
+
54
+ // The cut FACE under the pointer, when a cutaway is showing one.
55
+ //
56
+ // Nothing real is there to hit: a section view removes the near half by
57
+ // clipping, which the raycaster knows nothing about, so every triangle along
58
+ // that ray is either on the discarded side (rejected by isWorldPointVisible) or
59
+ // a back face three does not report at all. Clicking the largest surface in the
60
+ // view therefore selected nothing.
61
+ //
62
+ // What IS there is solid material, sliced open at the plane. So: find where the
63
+ // ray crosses into the retained half, and ask whether the sub-part is solid at
64
+ // that crossing. It is solid when the ray has entered it more often than it has
65
+ // left it — and the exits of a ray are the entries of the same ray reversed,
66
+ // which is how this stays inside three's front-face raycasting rather than
67
+ // flipping every material to DoubleSide behind the renderer's back.
68
+ function sectionHit(viewer, meshes, forwardHits, surfaceHit) {
69
+ const plane = viewer.getCutawayPlane?.(_cutPlane);
70
+ if (!plane) return null;
71
+ const ray = raycaster.ray;
72
+ // Only a ray crossing INTO the retained half can reveal a cut face; if the
73
+ // camera is already on that side, nothing in front of it was removed.
74
+ if (plane.normal.dot(ray.direction) <= SECTION_EPSILON) return null;
75
+ const distance = ray.distanceToPlane(plane);
76
+ if (distance == null || distance <= 0) return null;
77
+ // A retained surface nearer than the crossing is simply in front of the cut.
78
+ if (surfaceHit && surfaceHit.distance <= distance) return null;
79
+ // Nothing entered before the plane can be solid at it, so the reverse
80
+ // raycast below is pure cost on the common miss — a click on the backdrop.
81
+ if (!forwardHits.some((hit) => hit.distance < distance)) return null;
82
+ ray.at(distance, _sectionPoint);
83
+
84
+ raycaster.set(_sectionPoint, _reverseDirection.copy(ray.direction).negate());
85
+ const exitHits = raycaster.intersectObjects(meshes, false);
86
+
87
+ let best = null;
88
+ for (const mesh of meshes) {
89
+ let net = 0;
90
+ let entry = null;
91
+ for (const hit of forwardHits) {
92
+ if (hit.object !== mesh || hit.distance >= distance) continue;
93
+ net += 1;
94
+ entry ??= hit; // hits arrive sorted, so this is the nearest entry
95
+ }
96
+ for (const hit of exitHits) {
97
+ if (hit.object === mesh && hit.distance < distance) net -= 1;
98
+ }
99
+ if (net > 0 && (best == null || entry.distance < best.entry.distance)) best = { mesh, entry };
100
+ }
101
+ if (!best) return null;
102
+
103
+ const { mesh, entry } = best;
104
+ return {
105
+ mesh,
106
+ subPart: mesh.name,
107
+ // The triangle the ray entered through. It is not the one under the
108
+ // pointer — a cut face has no triangles — but it is the material the cut
109
+ // opened, and callers that index by triangle need something real.
110
+ triIndex: entry.faceIndex,
111
+ pointWorld: _sectionPoint.clone(),
112
+ pointLocal: worldToSubPartLocal(mesh, _sectionPoint),
113
+ // The cut face looks back at the discarded half, opposite the plane normal.
114
+ normalLocal: worldNormalInSubPartFrame(mesh, _sectionNormal.copy(plane.normal).negate()),
115
+ // Deliberately unattributed: a cut face is an artefact of the section, not
116
+ // a surface the part's build ever labelled.
117
+ feature: null,
118
+ onCutPlane: true,
119
+ };
120
+ }
121
+
122
+ // `includeSection` opts into the cut-face pick above. It is off by default so
123
+ // hover labels and measurement mode keep hitting real geometry only — a
124
+ // dimension pinned to a plane the user can move is not a dimension.
125
+ export function raycastViewer(viewer, clientX, clientY, { includeSection = false } = {}) {
37
126
  const rect = viewer.domElement.getBoundingClientRect();
38
127
  ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
39
128
  ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
@@ -43,6 +132,13 @@ export function raycastViewer(viewer, clientX, clientY) {
43
132
  const hit = hits.find((candidate) =>
44
133
  viewer.isWorldPointVisible?.(candidate.point) ?? true
45
134
  );
135
+ // Ordered before the surface answer, not after: the crossing into the
136
+ // retained half is nearer than anything retained, so when a cut face is
137
+ // there it is what the pointer is over.
138
+ if (includeSection) {
139
+ const section = sectionHit(viewer, meshes, hits, hit);
140
+ if (section) return section;
141
+ }
46
142
  if (!hit) return null;
47
143
  return {
48
144
  mesh: hit.object,
@@ -45,5 +45,9 @@ export function resolveSelection(part, ctx, hit) {
45
45
  // Feature attribution from the mesh payload (Solid.label() in the part's build) —
46
46
  // the same name the hover tooltip shows, so user, agent, and viewer share vocabulary.
47
47
  if (hit.feature) selection.feature = { label: hit.feature.label };
48
+ // A cutaway section pick: the point is inside the material, on the cut face,
49
+ // not on a surface the part was built with. Callers that describe the click
50
+ // in words need to say so — the coordinates alone read as a normal surface.
51
+ if (hit.onCutPlane) selection.onCutPlane = true;
48
52
  return selection;
49
53
  }
@@ -5,6 +5,8 @@ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
5
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
+ import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
9
+ import { flashWorldRadius } from "./pick-flash.js";
8
10
  import { createCameraTween } from "./camera-tween.js";
9
11
  import { orbitPose } from "./camera-orbit.js";
10
12
  import { orthoFrustum, perspectiveDistance } from "./projection.js";
@@ -1049,6 +1051,10 @@ export function createViewer(container, part) {
1049
1051
  try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
1050
1052
  }
1051
1053
  if (cutaway.isEnabled) cutaway.updateForCamera();
1054
+ // Re-size the pick markers against the pose this frame will actually draw:
1055
+ // a dot is only alive for about a second, but orbiting or zooming inside
1056
+ // that second must not resize it.
1057
+ for (const dot of flashDots) scaleFlashDot(dot);
1052
1058
  renderer.render(scene, activeCamera);
1053
1059
  cutaway.renderOverlay(renderer, activeCamera);
1054
1060
  }
@@ -1139,18 +1145,44 @@ export function createViewer(container, part) {
1139
1145
  function onCameraEnd(cb) { controls.addEventListener("end", cb); }
1140
1146
 
1141
1147
  // Transient marker at a world-space point — visual confirmation of a pick.
1148
+ //
1149
+ // Two things keep it visible where it used to disappear. It is ordered above
1150
+ // every band the cutaway raises its surfaces, edges and outlines into (a
1151
+ // section view moves them past 1,000,000, so the old fixed 999 was painted
1152
+ // over by the very geometry the marker sat on — and `depthTest: false` means
1153
+ // no depth is written either, so nothing behind it survives the overdraw).
1154
+ // And it is `transparent`, which puts it in the pass three draws LAST: a
1155
+ // translucent part puts the cutaway's caps in the transparent list, where an
1156
+ // opaque marker is already behind whatever its render order.
1157
+ //
1158
+ // The sphere is a UNIT sphere scaled per frame (see below) so it reads as a
1159
+ // constant handful of CSS pixels instead of a fixed millimetre size.
1160
+ const FLASH_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
1142
1161
  const flashTimers = new Set();
1162
+ const flashDots = new Set();
1163
+ const flashGeometry = new THREE.SphereGeometry(1, 16, 12);
1164
+ const flashViewport = new THREE.Vector2();
1165
+ function scaleFlashDot(dot) {
1166
+ renderer.getSize(flashViewport); // CSS px, which is what a pixel radius means
1167
+ dot.scale.setScalar(flashWorldRadius(activeCamera, dot.position, flashViewport.y));
1168
+ }
1143
1169
  function flashPoint(world) {
1144
1170
  const dot = new THREE.Mesh(
1145
- new THREE.SphereGeometry(1.2, 16, 12),
1146
- new THREE.MeshBasicMaterial({ color: 0xffcc33, depthTest: false })
1171
+ flashGeometry,
1172
+ new THREE.MeshBasicMaterial({
1173
+ color: 0xffcc33, depthTest: false, depthWrite: false, transparent: true,
1174
+ })
1147
1175
  );
1148
- dot.renderOrder = 999;
1176
+ dot.renderOrder = FLASH_RENDER_ORDER;
1149
1177
  dot.position.set(world[0], world[1], world[2]);
1178
+ scaleFlashDot(dot); // sized before its first frame, not one frame late
1150
1179
  scene.add(dot);
1180
+ flashDots.add(dot);
1151
1181
  const t = setTimeout(() => {
1152
1182
  flashTimers.delete(t);
1153
- scene.remove(dot); dot.geometry.dispose(); dot.material.dispose();
1183
+ flashDots.delete(dot);
1184
+ // The geometry is shared by every dot and freed in dispose(), not here.
1185
+ scene.remove(dot); dot.material.dispose();
1154
1186
  }, 1200);
1155
1187
  flashTimers.add(t);
1156
1188
  }
@@ -1179,6 +1211,11 @@ export function createViewer(container, part) {
1179
1211
  controls.dispose();
1180
1212
  for (const t of flashTimers) clearTimeout(t);
1181
1213
  flashTimers.clear();
1214
+ // A dot whose timer was just cancelled still holds its own material; the
1215
+ // sphere geometry is shared across all of them and freed once.
1216
+ for (const dot of flashDots) { scene.remove(dot); dot.material.dispose(); }
1217
+ flashDots.clear();
1218
+ flashGeometry.dispose();
1182
1219
  cutaway.dispose();
1183
1220
  for (const n of names) {
1184
1221
  const g = subCache[n];
@@ -1253,6 +1290,7 @@ export function createViewer(container, part) {
1253
1290
  flipCutaway: cutaway.flip,
1254
1291
  resetCutaway: cutaway.reset,
1255
1292
  isWorldPointVisible: cutaway.isPointVisible,
1293
+ getCutawayPlane: cutaway.getPlane,
1256
1294
  registerCutawayMaterial: cutaway.registerClippableMaterial,
1257
1295
  registerCanonicalCaptureHidden,
1258
1296
  onCutawayHandleHover: cutaway.onHandleHoverChange,
package/types/index.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface Selection {
28
28
  params: Record<string, ParamValue>;
29
29
  /** Present when the hit surface carries a `Solid.label()` name. */
30
30
  feature?: { label: string };
31
+ /**
32
+ * Present when the click landed on a cutaway's cut face. The point is inside
33
+ * the material rather than on a surface the part was built with, and the
34
+ * normal faces the half the section removed.
35
+ */
36
+ onCutPlane?: true;
31
37
  }
32
38
 
33
39
  export interface PickEvent {