Skip to content

Commit 1100e4f

Browse files
committed
release: SketchForge 1.0.5
1 parent 95b029e commit 1100e4f

16 files changed

Lines changed: 156 additions & 49 deletions

.github/SETUP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ permissions:
3232

3333
## Create a release
3434

35-
Push a tag such as `v1.0.4`, or run `Desktop Release` with `workflow_dispatch` and enter `1.0.4`.
35+
Push a tag such as `v1.0.5`, or run `Desktop Release` with `workflow_dispatch` and enter `1.0.5`.
3636

3737
The workflow builds these packages:
3838

.github/workflows/desktop-release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
workflow_dispatch:
99
inputs:
1010
version:
11-
description: "Version to release (for example 1.0.4)"
11+
description: "Version to release (for example 1.0.5)"
1212
required: true
1313
type: string
1414

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
<a href="https://github.com/Formsmith746/SketchForge-3D/stargazers"><img alt="Star SketchForge on GitHub" src="https://img.shields.io/github/stars/Formsmith746/SketchForge-3D?style=flat&logo=github"></a>
2020
<a href="https://github.com/sponsors/Formsmith746"><img alt="Sponsor SketchForge on GitHub" src="https://img.shields.io/badge/GitHub-Sponsor-ea4aaa?logo=githubsponsors&logoColor=white"></a>
2121
<img alt="Local first" src="https://img.shields.io/badge/local--first-no%20account-0ea5e9">
22-
<img alt="Version v1.0.4" src="https://img.shields.io/badge/version-v1.0.4-2563eb">
22+
<img alt="Version v1.0.5" src="https://img.shields.io/badge/version-v1.0.5-2563eb">
2323
</p>
2424
</div>
2525

apps/web/src/app/globals.css

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4737,16 +4737,16 @@ button {
47374737

47384738
.rotate-handle {
47394739
z-index: 3;
4740-
width: 42px;
4741-
height: 42px;
4740+
width: 52px;
4741+
height: 52px;
47424742
color: rgba(47, 61, 70, 0.6);
47434743
background: transparent;
47444744
border: 0;
47454745
border-radius: 0;
47464746
box-shadow: none;
47474747
cursor: grab;
47484748
opacity: 1;
4749-
transform: translate3d(var(--overlay-x, 0), var(--overlay-y, 0), 0) translate(-50%, -50%) rotate(var(--rotate-handle-angle, 0deg));
4749+
transform: translate3d(var(--overlay-x, 0), var(--overlay-y, 0), 0) translate(-50%, -50%);
47504750
}
47514751

47524752
.rotate-handle:hover {
@@ -4756,8 +4756,8 @@ button {
47564756
.rotate-handle-icon,
47574757
.rotate-handle-icon svg {
47584758
display: block;
4759-
width: 18px;
4760-
height: 18px;
4759+
width: 26px;
4760+
height: 26px;
47614761
}
47624762

47634763
.rotate-handle-icon {
@@ -4769,6 +4769,16 @@ button {
47694769

47704770
.rotate-handle-icon svg {
47714771
fill: currentColor;
4772+
transform: matrix(
4773+
var(--rotate-plane-a, 1),
4774+
var(--rotate-plane-b, 0),
4775+
var(--rotate-plane-c, 0),
4776+
var(--rotate-plane-d, 1),
4777+
0,
4778+
0
4779+
);
4780+
transform-box: fill-box;
4781+
transform-origin: center;
47724782
}
47734783

47744784
.rotation-edit {

apps/web/src/components/WorkplaneViewport.tsx

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
type PlacementWorkplane,
4343
} from "@/lib/placementWorkplane";
4444
import { regularPolygonFootprintScale } from "@/lib/regularPolygonFootprint";
45-
import { DEFAULT_SNAP_GRID, DEFAULT_WORKPLANE_WORKSPACE, normalizeSnapGrid, normalizeWorkspaceSettings, workplaneSettingsFingerprint, workspaceHydrationSyncDecision } from "@/lib/workplaneSettings";
45+
import { canBeginShapeDrag, DEFAULT_SNAP_GRID, DEFAULT_WORKPLANE_WORKSPACE, normalizeSnapGrid, normalizeWorkspaceSettings, workplaneSettingsFingerprint, workspaceHydrationSyncDecision } from "@/lib/workplaneSettings";
4646
import { interiorWorkplaneGridCoordinates, workplaneThemePalette, WORKPLANE_LINE_ELEVATION, WORKPLANE_MAJOR_GRID_INTERVAL } from "@/lib/workplaneGrid";
4747
import { cleanNearZero, cleanRotationDegrees, fallbackSolidColor, mirroredAxisCount, mirrorSign, preservesEdgeTreatmentSize, proportionalResizeScale, resizedImportedCoordinates, resizedImportedMeshPositions, resizedShapeSize, shapeDepth, shapeHasTaper, shapeOverallFootprintDimensions, shapeTaperDimensions, shapeTaperScaleAt, shapeWidth } from "@/lib/workplaneShapes";
4848
import { sphereTessellation } from "@/lib/sphereTessellation";
@@ -51,6 +51,7 @@ import {
5151
TransformOverlay,
5252
getElevationMeasureKey,
5353
measureKeyForHandle,
54+
normalizedRotationPlaneBasis,
5455
type DimensionMark,
5556
type EditingDimension,
5657
type EditingRotation,
@@ -1837,8 +1838,6 @@ function signedAngleAroundAxis(start: THREE.Vector3, current: THREE.Vector3, axi
18371838

18381839
const ROTATION_HANDLE_SIDE_HYSTERESIS = 0.22;
18391840
const ROTATION_HANDLE_DOMINANCE_HYSTERESIS = 0.18;
1840-
const ROTATION_UPPER_HANDLE_ICON_ANGLE = 0;
1841-
const ROTATION_BOTTOM_HANDLE_ICON_ANGLE = 0;
18421841
function signedRotationSide(value: number, previous: RotationHandleSide | undefined, positiveSide: RotationHandleSide, negativeSide: RotationHandleSide) {
18431842
if (previous === positiveSide && value > -ROTATION_HANDLE_SIDE_HYSTERESIS) {
18441843
return previous;
@@ -1896,9 +1895,10 @@ function rotationHandleSidesForCamera(
18961895
const viewZ = viewZRaw / length;
18971896
const previous = state.rotationHandleSides ?? undefined;
18981897
const next: RotationHandleSides = {
1899-
x: signedRotationSide(viewX, previous?.x, "right", "left"),
1898+
// Keep the two upper rotation handles on the faces opposite the camera.
1899+
x: signedRotationSide(viewX, previous?.x, "left", "right"),
19001900
y: dominantRotationSide(viewX, viewZ, previous?.y),
1901-
z: signedRotationSide(viewZ, previous?.z, "near", "far"),
1901+
z: signedRotationSide(viewZ, previous?.z, "far", "near"),
19021902
};
19031903
state.rotationHandleSides = next;
19041904
return next;
@@ -4262,6 +4262,9 @@ export function WorkplaneViewport({
42624262
if (!alreadySelected) {
42634263
onSelectShape(id);
42644264
}
4265+
if (!canBeginShapeDrag(workspaceRef.current.selectBeforeMove, alreadySelected)) {
4266+
return;
4267+
}
42654268
if (shape.locked) {
42664269
return;
42674270
}
@@ -6231,7 +6234,10 @@ function updateTransformOverlayDom(state: ThreeState, next: TransformOverlayStat
62316234
}
62326235
element.style.setProperty("--overlay-x", `${handle.x}px`);
62336236
element.style.setProperty("--overlay-y", `${handle.y}px`);
6234-
element.style.setProperty("--rotate-handle-angle", `${handle.angle}deg`);
6237+
element.style.setProperty("--rotate-plane-a", String(handle.plane.a));
6238+
element.style.setProperty("--rotate-plane-b", String(handle.plane.b));
6239+
element.style.setProperty("--rotate-plane-c", String(handle.plane.c));
6240+
element.style.setProperty("--rotate-plane-d", String(handle.plane.d));
62356241
});
62366242
}
62376243

@@ -6438,37 +6444,34 @@ function syncTransformOverlay(
64386444
};
64396445
const rotateLeft = screenOffsetFromCenter(project(sidePoint(rotationSides.x, frame.max.y)), 24);
64406446
const rotateRight = screenOffsetFromCenter(project(sidePoint(rotationSides.z, frame.max.y)), 28);
6441-
const rotateBottom = screenOffsetFromCenter(project(sidePoint(rotationSides.y, footprintY)), 34);
6447+
const rotateBottomAnchor = screenOffsetFromCenter(project(sidePoint(rotationSides.y, footprintY)), 26);
6448+
const rotateBottom = { ...rotateBottomAnchor, y: rotateBottomAnchor.y - 5 };
64426449
const xFaceCenter = sidePoint(rotationSides.x, 0);
64436450
const zFaceCenter = sidePoint(rotationSides.z, 0);
64446451
const yFaceCenter = bottomCenterWorld;
6445-
const projectedAxisAngle = (centerWorld: THREE.Vector3, axis: THREE.Vector3) => {
6446-
const from = project(centerWorld.clone().addScaledVector(axis, -1));
6447-
const to = project(centerWorld.clone().addScaledVector(axis, 1));
6448-
return THREE.MathUtils.radToDeg(Math.atan2(to.y - from.y, to.x - from.x));
6449-
};
6450-
const rotateWithWorkplane = !placementWorkplaneIsBase(activeWorkplane);
6451-
const xRotateAngle = rotateWithWorkplane
6452-
? projectedAxisAngle(xFaceCenter, zFootAxis)
6453-
: ROTATION_UPPER_HANDLE_ICON_ANGLE;
6454-
const zRotateAngle = rotateWithWorkplane
6455-
? projectedAxisAngle(zFaceCenter, xFootAxis)
6456-
: ROTATION_UPPER_HANDLE_ICON_ANGLE;
6457-
const yRotateTangent = rotationSides.y === "right" || rotationSides.y === "left"
6458-
? zFootAxis
6459-
: xFootAxis;
6460-
const yRotateAngle = rotateWithWorkplane
6461-
? projectedAxisAngle(yFaceCenter, yRotateTangent)
6462-
: ROTATION_BOTTOM_HANDLE_ICON_ANGLE;
6452+
const yRotationAxes = rotationSides.y === "near"
6453+
? { u: xFootAxis, v: zFootAxis }
6454+
: rotationSides.y === "far"
6455+
? { u: xFootAxis.clone().multiplyScalar(-1), v: zFootAxis.clone().multiplyScalar(-1) }
6456+
: rotationSides.y === "right"
6457+
? { u: zFootAxis.clone().multiplyScalar(-1), v: xFootAxis }
6458+
: { u: zFootAxis, v: xFootAxis.clone().multiplyScalar(-1) };
64636459
const planeRadius = 154;
6464-
const planeWorldStep = Math.max(12, Math.max(frame.width, frame.depth, frame.height) * 0.78);
6460+
const planeWorldStep = Math.max(0.1, cameraDistance * 0.01);
64656461
const makePlaneView = (centerWorld: THREE.Vector3, uAxis: THREE.Vector3, vAxis: THREE.Vector3): RotationPlaneView => {
64666462
const screenCenter = project(centerWorld);
6467-
const u = project(centerWorld.clone().add(uAxis.clone().multiplyScalar(planeWorldStep)));
6468-
const v = project(centerWorld.clone().add(vAxis.clone().multiplyScalar(planeWorldStep)));
6469-
const du = { x: u.x - screenCenter.x, y: u.y - screenCenter.y };
6470-
const dv = { x: v.x - screenCenter.x, y: v.y - screenCenter.y };
6471-
const longest = Math.max(12, Math.hypot(du.x, du.y), Math.hypot(dv.x, dv.y));
6463+
const uOffset = uAxis.clone().multiplyScalar(planeWorldStep);
6464+
const vOffset = vAxis.clone().multiplyScalar(planeWorldStep);
6465+
const uStart = project(centerWorld.clone().sub(uOffset));
6466+
const uEnd = project(centerWorld.clone().add(uOffset));
6467+
const vStart = project(centerWorld.clone().sub(vOffset));
6468+
const vEnd = project(centerWorld.clone().add(vOffset));
6469+
const du = { x: (uEnd.x - uStart.x) / 2, y: (uEnd.y - uStart.y) / 2 };
6470+
const dv = { x: (vEnd.x - vStart.x) / 2, y: (vEnd.y - vStart.y) / 2 };
6471+
const longest = Math.max(Math.hypot(du.x, du.y), Math.hypot(dv.x, dv.y));
6472+
if (!Number.isFinite(longest) || longest < 0.000001) {
6473+
return { x: screenCenter.x, y: screenCenter.y, a: 1, b: 0, c: 0, d: 1 };
6474+
}
64726475
const scale = planeRadius / longest / 100;
64736476
return {
64746477
x: screenCenter.x,
@@ -6499,6 +6502,19 @@ function syncTransformOverlay(
64996502
y: makePlaneView(yFaceCenter, xFootAxis, zFootAxis),
65006503
z: makePlaneView(zFaceCenter, xFootAxis, yFootAxis),
65016504
};
6505+
const makeCameraPlaneView = (uAxis: THREE.Vector3, vAxis: THREE.Vector3): RotationPlaneView => {
6506+
const u = uAxis.clone().transformDirection(state.camera.matrixWorldInverse);
6507+
const v = vAxis.clone().transformDirection(state.camera.matrixWorldInverse);
6508+
return { x: 0, y: 0, a: u.x, b: -u.y, c: v.x, d: -v.y };
6509+
};
6510+
const rotationHandlePlanes: Record<RotationAxis, RotationPlaneView> = {
6511+
// Project only the two plane axes through the camera rotation. Ignoring
6512+
// perspective translation keeps glyph appearance independent of object
6513+
// length and screen position while preserving camera foreshortening.
6514+
x: makeCameraPlaneView(zFootAxis, yFootAxis),
6515+
y: makeCameraPlaneView(yRotationAxes.u, yRotationAxes.v),
6516+
z: makeCameraPlaneView(xFootAxis, yFootAxis),
6517+
};
65026518

65036519
const next = {
65046520
id: frame.ids.join("|"),
@@ -6521,9 +6537,9 @@ function syncTransformOverlay(
65216537
{ key: liftHandleKey, className: showLowerHandles ? "height-lift lower" : "height-lift", kind: "lift" as const, x: liftPoint.x, y: liftPoint.y, title: "Lift", angle: liftHandleAngle },
65226538
],
65236539
rotateHandles: [
6524-
{ key: "rotate-left", className: "screen-left", x: rotateLeft.x, y: rotateLeft.y, angle: xRotateAngle },
6525-
{ key: "rotate-right", className: "screen-right", x: rotateRight.x, y: rotateRight.y, angle: zRotateAngle },
6526-
{ key: "rotate-bottom", className: "screen-bottom", x: rotateBottom.x, y: rotateBottom.y, angle: yRotateAngle },
6540+
{ key: "rotate-left", className: "screen-left", x: rotateLeft.x, y: rotateLeft.y, plane: normalizedRotationPlaneBasis(rotationHandlePlanes.x, true) },
6541+
{ key: "rotate-right", className: "screen-right", x: rotateRight.x, y: rotateRight.y, plane: normalizedRotationPlaneBasis(rotationHandlePlanes.z, true) },
6542+
{ key: "rotate-bottom", className: "screen-bottom", x: rotateBottom.x, y: rotateBottom.y, plane: normalizedRotationPlaneBasis(rotationHandlePlanes.y, true) },
65276543
],
65286544
dimensions: dimensionMarks,
65296545
rotationWheel: rotationWheels.y,

apps/web/src/components/workplane/TransformOverlay.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import {
99
export {
1010
getElevationMeasureKey,
1111
measureKeyForHandle,
12+
normalizedRotationPlaneBasis,
1213
type DimensionMark,
1314
type EditingDimension,
1415
type EditingRotation,
1516
type PinnedRotationWheelView,
1617
type RotationAxis,
18+
type RotationPlaneBasis,
1719
type RotationPlaneView,
1820
type RotationReadout,
1921
type RotationWheelView,
@@ -240,7 +242,14 @@ export function TransformOverlay({
240242
<button
241243
key={handle.key}
242244
className={`rotate-handle ${handle.className}`}
243-
style={{ "--overlay-x": `${handle.x}px`, "--overlay-y": `${handle.y}px`, "--rotate-handle-angle": `${handle.angle}deg` } as CSSProperties}
245+
style={{
246+
"--overlay-x": `${handle.x}px`,
247+
"--overlay-y": `${handle.y}px`,
248+
"--rotate-plane-a": handle.plane.a,
249+
"--rotate-plane-b": handle.plane.b,
250+
"--rotate-plane-c": handle.plane.c,
251+
"--rotate-plane-d": handle.plane.d,
252+
} as CSSProperties}
244253
title="Rotate"
245254
onPointerDown={(event) => {
246255
if (event.button === 0) {

apps/web/src/components/workplane/WorkspaceSettingsModal.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,11 @@ export function WorkspaceSettingsModal({
202202
checked={moveDimensionsEnabled}
203203
onChange={onMoveDimensionsEnabledChange}
204204
/>
205+
<WorkspaceToggle
206+
label="Select before moving"
207+
checked={workspace.selectBeforeMove}
208+
onChange={(selectBeforeMove) => patchWorkspace({ selectBeforeMove })}
209+
/>
205210
<WorkspaceToggle label="Show shadows" checked={workspace.showShadows} onChange={(showShadows) => patchWorkspace({ showShadows })} />
206211
<WorkspaceToggle
207212
label="Cruise when adding new shapes"

apps/web/src/components/workplane/transformOverlayTypes.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ export type RotationPlaneView = {
1313
d: number;
1414
};
1515

16+
export type RotationPlaneBasis = Pick<RotationPlaneView, "a" | "b" | "c" | "d">;
17+
18+
export function normalizedRotationPlaneBasis(plane: RotationPlaneView, invertVerticalAxis = false): RotationPlaneBasis {
19+
const longestAxis = Math.max(Math.hypot(plane.a, plane.b), Math.hypot(plane.c, plane.d));
20+
if (!Number.isFinite(longestAxis) || longestAxis < 0.000001) {
21+
return { a: 1, b: 0, c: 0, d: 1 };
22+
}
23+
return {
24+
a: plane.a / longestAxis,
25+
b: plane.b / longestAxis,
26+
c: (plane.c / longestAxis) * (invertVerticalAxis ? -1 : 1),
27+
d: (plane.d / longestAxis) * (invertVerticalAxis ? -1 : 1),
28+
};
29+
}
30+
1631
export type PinnedRotationWheelView = {
1732
axis: RotationAxis;
1833
wheel: RotationWheelView;
@@ -46,7 +61,7 @@ export type TransformOverlayState = {
4661
height: number;
4762
guides: Array<{ x1: number; y1: number; x2: number; y2: number }>;
4863
handles: Array<{ key: string; className: string; kind: TransformHandleKind; x: number; y: number; title: string; angle?: number }>;
49-
rotateHandles: Array<{ key: string; className: string; x: number; y: number; angle: number }>;
64+
rotateHandles: Array<{ key: string; className: string; x: number; y: number; plane: RotationPlaneBasis }>;
5065
dimensions: Record<string, DimensionMark[]>;
5166
rotationWheel: RotationWheelView | null;
5267
rotationWheels: Record<RotationAxis, RotationWheelView>;

apps/web/src/lib/skfProject.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { GridSize, ProjectAsset, ProjectAssetSourceFormat, SketchOperation,
1212
export const SKF_SCHEMA_ID = "com.sketchforge.project";
1313
export const SKF_FORMAT_VERSION = 1;
1414
export const SKF_MINIMUM_READER_VERSION = 1;
15-
export const SKF_CREATED_WITH_VERSION = "1.0.4";
15+
export const SKF_CREATED_WITH_VERSION = "1.0.5";
1616
export const SKF_MEDIA_TYPE = "application/vnd.sketchforge.project+zip";
1717

1818
export const SKF_LIMITS = {

apps/web/src/lib/workplaneSettings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const DEFAULT_WORKPLANE_WORKSPACE: WorkplaneWorkspaceSettings = {
1515
showShadows: true,
1616
showGrid: true,
1717
cruiseShapes: true,
18+
selectBeforeMove: false,
1819
zoomSpeed: 5,
1920
units: "Metric (Default)",
2021
scale: "1:1 (millimeters)",
@@ -68,6 +69,7 @@ export function normalizeWorkspaceSettings(value: unknown, fallback: WorkplaneWo
6869
showShadows: booleanOrDefault(candidate.showShadows, fallback.showShadows),
6970
showGrid: booleanOrDefault(candidate.showGrid, fallback.showGrid),
7071
cruiseShapes: booleanOrDefault(candidate.cruiseShapes, fallback.cruiseShapes),
72+
selectBeforeMove: booleanOrDefault(candidate.selectBeforeMove, fallback.selectBeforeMove),
7173
zoomSpeed: numberOrDefault(candidate.zoomSpeed, fallback.zoomSpeed),
7274
units,
7375
scale: normalizeScaleForUnits(units, stringOrDefault(candidate.scale, fallback.scale)),
@@ -76,6 +78,10 @@ export function normalizeWorkspaceSettings(value: unknown, fallback: WorkplaneWo
7678
};
7779
}
7880

81+
export function canBeginShapeDrag(selectBeforeMove: boolean, alreadySelected: boolean) {
82+
return !selectBeforeMove || alreadySelected;
83+
}
84+
7985
export function workplaneSettingsFingerprint(workspace: WorkplaneWorkspaceSettings, snapGrid: GridSize) {
8086
return JSON.stringify({ workspace, snapGrid });
8187
}

0 commit comments

Comments
 (0)