Compare commits

...

8 Commits

Author SHA1 Message Date
5474b3409e Merge branch 'main' into kurt-contraint-colours 2025-07-02 12:18:46 +10:00
29ae16fbf0 fix console noise 2025-07-02 11:55:50 +10:00
38ee257996 Merge branch 'main' into kurt-contraint-colours 2025-07-02 06:03:07 +10:00
47c29b2681 Update snapshots 2025-07-01 08:18:39 +00:00
23f51d73ee Update snapshots 2025-07-01 08:04:33 +00:00
f752a496de fix package 2025-07-01 17:51:02 +10:00
6545fb6db0 package 2025-07-01 17:26:15 +10:00
e63eb18d65 constraint colors 2025-07-01 16:46:43 +10:00
13 changed files with 455 additions and 17 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

18
package-lock.json generated
View File

@ -43,6 +43,7 @@
"bson": "^6.10.3", "bson": "^6.10.3",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"culori": "^4.0.2",
"decamelize": "^6.0.0", "decamelize": "^6.0.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
@ -92,6 +93,7 @@
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7", "@testing-library/react": "^15.0.7",
"@types/culori": "^4.0.0",
"@types/diff": "^7.0.2", "@types/diff": "^7.0.2",
"@types/electron": "^1.6.10", "@types/electron": "^1.6.10",
"@types/hammerjs": "^2.0.46", "@types/hammerjs": "^2.0.46",
@ -7392,6 +7394,13 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/culori": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/culori/-/culori-4.0.0.tgz",
"integrity": "sha512-aFljQwjb++sl6TAyEXeHTiK/fk9epZOQ+nMmadjnAvzZFIvNoQ0x8XQYfcOaRTBwmDUPUlghhZCJ66MTcqQAsg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -11823,6 +11832,15 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/culori": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/culori/-/culori-4.0.2.tgz",
"integrity": "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",

View File

@ -45,6 +45,7 @@
"bson": "^6.10.3", "bson": "^6.10.3",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"culori": "^4.0.2",
"decamelize": "^6.0.0", "decamelize": "^6.0.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
@ -170,6 +171,7 @@
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7", "@testing-library/react": "^15.0.7",
"@types/culori": "^4.0.0",
"@types/diff": "^7.0.2", "@types/diff": "^7.0.2",
"@types/electron": "^1.6.10", "@types/electron": "^1.6.10",
"@types/hammerjs": "^2.0.46", "@types/hammerjs": "^2.0.46",

View File

@ -324,6 +324,8 @@ export class SceneEntities {
group: segment, group: segment,
scale: factor, scale: factor,
sceneInfra: this.sceneInfra, sceneInfra: this.sceneInfra,
// Note: AST and code not available in onCamChange, so constraints won't be checked here
// This is primarily for scaling changes
}) })
callBack && !err(callBack) && callbacks.push(callBack) callBack && !err(callBack) && callbacks.push(callBack)
if (segment.name === PROFILE_START) { if (segment.name === PROFILE_START) {
@ -729,6 +731,8 @@ export class SceneEntities {
scale, scale,
theme: this.sceneInfra._theme, theme: this.sceneInfra._theme,
isDraft: false, isDraft: false,
ast: maybeModdedAst,
code: this.codeManager.code,
}) })
_profileStart.layers.set(SKETCH_LAYER) _profileStart.layers.set(SKETCH_LAYER)
_profileStart.traverse((child) => { _profileStart.traverse((child) => {
@ -866,6 +870,8 @@ export class SceneEntities {
isSelected, isSelected,
sceneInfra: this.sceneInfra, sceneInfra: this.sceneInfra,
selection, selection,
ast: maybeModdedAst,
code: this.codeManager.code,
}) })
if (err(result)) return if (err(result)) return
const { group: _group, updateOverlaysCallback } = result const { group: _group, updateOverlaysCallback } = result
@ -3252,6 +3258,8 @@ export class SceneEntities {
scale: factor, scale: factor,
prevSegment: sgPaths[index - 1], prevSegment: sgPaths[index - 1],
sceneInfra: this.sceneInfra, sceneInfra: this.sceneInfra,
ast: modifiedAst,
code: this.codeManager.code,
}) })
if (callBack && !err(callBack)) return callBack if (callBack && !err(callBack)) return callBack

View File

@ -76,12 +76,17 @@ import {
} from '@src/clientSideScene/sceneUtils' } from '@src/clientSideScene/sceneUtils'
import { angleLengthInfo } from '@src/components/Toolbar/angleLengthInfo' import { angleLengthInfo } from '@src/components/Toolbar/angleLengthInfo'
import type { Coords2d } from '@src/lang/std/sketch' import type { Coords2d } from '@src/lang/std/sketch'
import { getConstraintInfoKw } from '@src/lang/std/sketch'
import type { SegmentInputs } from '@src/lang/std/stdTypes' import type { SegmentInputs } from '@src/lang/std/stdTypes'
import type { PathToNode } from '@src/lang/wasm' import type { PathToNode, Program } from '@src/lang/wasm'
import { getTangentialArcToInfo } from '@src/lang/wasm' import { getTangentialArcToInfo } from '@src/lang/wasm'
import { getNodeFromPath } from '@src/lang/queryAst'
import type { Selections } from '@src/lib/selections' import type { Selections } from '@src/lib/selections'
import type { Themes } from '@src/lib/theme' import type { Themes } from '@src/lib/theme'
import { getThemeColorForThreeJs } from '@src/lib/theme' import {
getThemeColorForThreeJs,
getPrimaryColorForThreeJs,
} from '@src/lib/theme'
import { err } from '@src/lib/trap' import { err } from '@src/lib/trap'
import { isClockwise, normaliseAngle, roundOff } from '@src/lib/utils' import { isClockwise, normaliseAngle, roundOff } from '@src/lib/utils'
import { getTangentPointFromPreviousArc } from '@src/lib/utils2d' import { getTangentPointFromPreviousArc } from '@src/lib/utils2d'
@ -95,6 +100,7 @@ import toast from 'react-hot-toast'
import { ARG_INTERIOR_ABSOLUTE } from '@src/lang/constants' import { ARG_INTERIOR_ABSOLUTE } from '@src/lang/constants'
const ANGLE_INDICATOR_RADIUS = 30 // in px const ANGLE_INDICATOR_RADIUS = 30 // in px
interface CreateSegmentArgs { interface CreateSegmentArgs {
input: SegmentInputs input: SegmentInputs
prevSegment: Sketch['paths'][number] prevSegment: Sketch['paths'][number]
@ -108,6 +114,9 @@ interface CreateSegmentArgs {
isSelected?: boolean isSelected?: boolean
sceneInfra: SceneInfra sceneInfra: SceneInfra
selection?: Selections selection?: Selections
// Add optional AST and code for constraint checking
ast?: Program
code?: string
} }
interface UpdateSegmentArgs { interface UpdateSegmentArgs {
@ -116,6 +125,9 @@ interface UpdateSegmentArgs {
group: Group group: Group
sceneInfra: SceneInfra sceneInfra: SceneInfra
scale?: number scale?: number
// Add optional AST and code for constraint checking
ast?: Program
code?: string
} }
interface CreateSegmentResult { interface CreateSegmentResult {
@ -144,6 +156,55 @@ export interface SegmentUtils {
) => CreateSegmentResult['updateOverlaysCallback'] | Error ) => CreateSegmentResult['updateOverlaysCallback'] | Error
} }
/**
* Checks if a segment is fully constrained by examining all its constraint info
*/
function isSegmentFullyConstrained(
pathToNode: PathToNode,
ast: Program,
code: string
): boolean {
try {
const nodeMeta = getNodeFromPath<any>(ast, pathToNode)
if (err(nodeMeta) || nodeMeta.node.type !== 'CallExpressionKw') {
return false
}
const constraintInfos = getConstraintInfoKw(nodeMeta.node, code, pathToNode)
// If there are no constraints, consider it not fully constrained
if (constraintInfos.length === 0) {
return false
}
// Check if all constraints are constrained
return constraintInfos.every((info) => info.isConstrained)
} catch (error) {
console.warn('Error checking segment constraints:', error)
return false
}
}
/**
* Gets the appropriate color for a segment based on selection, constraints, and theme
*/
function getSegmentColor({
theme,
isSelected,
callExpName = '',
isFullyConstrained = false,
}: {
theme: Themes
isSelected: boolean
callExpName?: string
isFullyConstrained: boolean
}): number {
if (isSelected) return 0x0000ff // Blue for selected
if (callExpName === 'close') return 0x444444 // Gray for close segments
if (!isFullyConstrained) return getPrimaryColorForThreeJs() // Primary color for unconstrained segments
return getThemeColorForThreeJs(theme) // Default theme color for constrained segments
}
class StraightSegment implements SegmentUtils { class StraightSegment implements SegmentUtils {
init: SegmentUtils['init'] = ({ init: SegmentUtils['init'] = ({
input, input,
@ -158,13 +219,32 @@ class StraightSegment implements SegmentUtils {
sceneInfra, sceneInfra,
prevSegment, prevSegment,
selection, selection,
ast,
code,
}) => { }) => {
if (input.type !== 'straight-segment') if (input.type !== 'straight-segment')
return new Error('Invalid segment type') return new Error('Invalid segment type')
const { from, to } = input const { from, to } = input
const baseColor =
callExpName === 'close' ? 0x444444 : getThemeColorForThreeJs(theme) // Check if segment is fully constrained (only if we have AST and code)
const color = isSelected ? 0x0000ff : baseColor const isFullyConstrained =
callExpName === 'close'
? true
: ast && code
? isSegmentFullyConstrained(pathToNode, ast, code)
: false
const color = getSegmentColor({
theme,
isSelected: !!isSelected,
callExpName,
isFullyConstrained,
})
const baseColor = !isFullyConstrained
? getPrimaryColorForThreeJs()
: callExpName === 'close'
? 0x444444
: getThemeColorForThreeJs(theme)
const meshType = isDraftSegment const meshType = isDraftSegment
? STRAIGHT_SEGMENT_DASH ? STRAIGHT_SEGMENT_DASH
: STRAIGHT_SEGMENT_BODY : STRAIGHT_SEGMENT_BODY
@ -250,12 +330,40 @@ class StraightSegment implements SegmentUtils {
group, group,
scale = 1, scale = 1,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'straight-segment') if (input.type !== 'straight-segment')
return new Error('Invalid segment type') return new Error('Invalid segment type')
const { from, to } = input const { from, to } = input
group.userData.from = from group.userData.from = from
group.userData.to = to group.userData.to = to
// Check if segment is fully constrained and update color if needed
if (ast && code) {
const pathToNode = group.userData.pathToNode
const isFullyConstrained =
group.userData.callExpName === 'close'
? true
: isSegmentFullyConstrained(pathToNode, ast, code)
const color = getSegmentColor({
theme: sceneInfra._theme,
isSelected: group.userData.isSelected,
callExpName: group.userData.callExpName,
isFullyConstrained,
})
// Update the material color
const straightSegmentBody = group.children.find(
(child) => child.userData.type === STRAIGHT_SEGMENT_BODY
) as Mesh
if (
straightSegmentBody &&
straightSegmentBody.material instanceof MeshBasicMaterial
) {
straightSegmentBody.material.color.set(color)
}
}
const shape = createLineShape(scale) const shape = createLineShape(scale)
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group
@ -391,6 +499,8 @@ class TangentialArcToSegment implements SegmentUtils {
theme, theme,
isSelected, isSelected,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'straight-segment') if (input.type !== 'straight-segment')
return new Error('Invalid segment type') return new Error('Invalid segment type')
@ -409,8 +519,19 @@ class TangentialArcToSegment implements SegmentUtils {
isDashed: isDraftSegment, isDashed: isDraftSegment,
scale, scale,
}) })
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor // Check if segment is fully constrained (only if we have AST and code)
const isFullyConstrained =
ast && code ? isSegmentFullyConstrained(pathToNode, ast, code) : false
const color = getSegmentColor({
theme,
isSelected: !!isSelected,
isFullyConstrained,
})
const baseColor = !isFullyConstrained
? getPrimaryColorForThreeJs()
: getThemeColorForThreeJs(theme)
const body = new MeshBasicMaterial({ color }) const body = new MeshBasicMaterial({ color })
const mesh = new Mesh(geometry, body) const mesh = new Mesh(geometry, body)
const arrowGroup = createArrowhead(scale, theme, color) const arrowGroup = createArrowhead(scale, theme, color)
@ -453,6 +574,8 @@ class TangentialArcToSegment implements SegmentUtils {
group, group,
scale = 1, scale = 1,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'straight-segment') if (input.type !== 'straight-segment')
return new Error('Invalid segment type') return new Error('Invalid segment type')
@ -460,6 +583,32 @@ class TangentialArcToSegment implements SegmentUtils {
group.userData.from = from group.userData.from = from
group.userData.to = to group.userData.to = to
group.userData.prevSegment = prevSegment group.userData.prevSegment = prevSegment
// Check if segment is fully constrained and update color if needed
if (ast && code) {
const pathToNode = group.userData.pathToNode
const isFullyConstrained = isSegmentFullyConstrained(
pathToNode,
ast,
code
)
const color = getSegmentColor({
theme: sceneInfra._theme,
isSelected: group.userData.isSelected,
isFullyConstrained,
})
// Update the material color
const tangentialArcSegmentBody = group.children.find(
(child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY
) as Mesh
if (
tangentialArcSegmentBody &&
tangentialArcSegmentBody.material instanceof MeshBasicMaterial
) {
tangentialArcSegmentBody.material.color.set(color)
}
}
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE) const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
@ -588,13 +737,26 @@ class CircleSegment implements SegmentUtils {
theme, theme,
isSelected, isSelected,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'arc-segment') { if (input.type !== 'arc-segment') {
return new Error('Invalid segment type') return new Error('Invalid segment type')
} }
const { from, center, radius } = input const { from, center, radius } = input
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor // Check if segment is fully constrained
const isFullyConstrained =
ast && code ? isSegmentFullyConstrained(pathToNode, ast, code) : false
const color = getSegmentColor({
theme,
isSelected: !!isSelected,
isFullyConstrained,
})
const baseColor = !isFullyConstrained
? getPrimaryColorForThreeJs()
: getThemeColorForThreeJs(theme)
const group = new Group() const group = new Group()
const geometry = createArcGeometry({ const geometry = createArcGeometry({
@ -678,6 +840,8 @@ class CircleSegment implements SegmentUtils {
group, group,
scale = 1, scale = 1,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'arc-segment') { if (input.type !== 'arc-segment') {
return new Error('Invalid segment type') return new Error('Invalid segment type')
@ -687,6 +851,32 @@ class CircleSegment implements SegmentUtils {
group.userData.center = center group.userData.center = center
group.userData.radius = radius group.userData.radius = radius
group.userData.prevSegment = prevSegment group.userData.prevSegment = prevSegment
// Check if segment is fully constrained and update color if needed
if (ast && code) {
const pathToNode = group.userData.pathToNode
const isFullyConstrained = isSegmentFullyConstrained(
pathToNode,
ast,
code
)
const color = getSegmentColor({
theme: sceneInfra._theme,
isSelected: group.userData.isSelected,
isFullyConstrained,
})
// Update the material color
const circleSegmentBody = group.children.find(
(child) => child.userData.type === CIRCLE_SEGMENT_BODY
) as Mesh
if (
circleSegmentBody &&
circleSegmentBody.material instanceof MeshBasicMaterial
) {
circleSegmentBody.material.color.set(color)
}
}
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const radiusLengthIndicator = group.getObjectByName( const radiusLengthIndicator = group.getObjectByName(
SEGMENT_LENGTH_LABEL SEGMENT_LENGTH_LABEL
@ -831,6 +1021,8 @@ class CircleThreePointSegment implements SegmentUtils {
isSelected = false, isSelected = false,
sceneInfra, sceneInfra,
prevSegment, prevSegment,
ast,
code,
}) => { }) => {
if (input.type !== 'circle-three-point-segment') { if (input.type !== 'circle-three-point-segment') {
return new Error('Invalid segment type') return new Error('Invalid segment type')
@ -845,8 +1037,19 @@ class CircleThreePointSegment implements SegmentUtils {
p3[1] p3[1]
) )
const center: [number, number] = [center_x, center_y] const center: [number, number] = [center_x, center_y]
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor // Check if segment is fully constrained
const isFullyConstrained =
ast && code ? isSegmentFullyConstrained(pathToNode, ast, code) : false
const color = getSegmentColor({
theme,
isSelected: !!isSelected,
isFullyConstrained,
})
const baseColor = !isFullyConstrained
? getPrimaryColorForThreeJs()
: getThemeColorForThreeJs(theme)
const group = new Group() const group = new Group()
const geometry = createArcGeometry({ const geometry = createArcGeometry({
@ -919,6 +1122,8 @@ class CircleThreePointSegment implements SegmentUtils {
group, group,
scale = 1, scale = 1,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'circle-three-point-segment') { if (input.type !== 'circle-three-point-segment') {
return new Error('Invalid segment type') return new Error('Invalid segment type')
@ -927,6 +1132,32 @@ class CircleThreePointSegment implements SegmentUtils {
group.userData.p1 = p1 group.userData.p1 = p1
group.userData.p2 = p2 group.userData.p2 = p2
group.userData.p3 = p3 group.userData.p3 = p3
// Check if segment is fully constrained and update color if needed
if (ast && code) {
const pathToNode = group.userData.pathToNode
const isFullyConstrained = isSegmentFullyConstrained(
pathToNode,
ast,
code
)
const color = getSegmentColor({
theme: sceneInfra._theme,
isSelected: group.userData.isSelected,
isFullyConstrained,
})
// Update the material color
const circleSegmentBody = group.children.find(
(child) => child.userData.type === CIRCLE_THREE_POINT_SEGMENT_BODY
) as Mesh
if (
circleSegmentBody &&
circleSegmentBody.material instanceof MeshBasicMaterial
) {
circleSegmentBody.material.color.set(color)
}
}
const { center_x, center_y, radius } = calculate_circle_from_3_points( const { center_x, center_y, radius } = calculate_circle_from_3_points(
p1[0], p1[0],
p1[1], p1[1],
@ -1048,13 +1279,26 @@ class ArcSegment implements SegmentUtils {
theme, theme,
isSelected, isSelected,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'arc-segment') { if (input.type !== 'arc-segment') {
return new Error('Invalid segment type') return new Error('Invalid segment type')
} }
const { from, to, center, radius, ccw } = input const { from, to, center, radius, ccw } = input
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor // Check if segment is fully constrained
const isFullyConstrained =
ast && code ? isSegmentFullyConstrained(pathToNode, ast, code) : false
const color = getSegmentColor({
theme,
isSelected: !!isSelected,
isFullyConstrained,
})
const baseColor = !isFullyConstrained
? getPrimaryColorForThreeJs()
: getThemeColorForThreeJs(theme)
// Calculate start and end angles // Calculate start and end angles
const startAngle = Math.atan2(from[1] - center[1], from[0] - center[0]) const startAngle = Math.atan2(from[1] - center[1], from[0] - center[0])
@ -1195,6 +1439,8 @@ class ArcSegment implements SegmentUtils {
group, group,
scale = 1, scale = 1,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'arc-segment') { if (input.type !== 'arc-segment') {
return new Error('Invalid segment type') return new Error('Invalid segment type')
@ -1207,6 +1453,32 @@ class ArcSegment implements SegmentUtils {
group.userData.ccw = ccw group.userData.ccw = ccw
group.userData.prevSegment = prevSegment group.userData.prevSegment = prevSegment
// Check if segment is fully constrained and update color if needed
if (ast && code) {
const pathToNode = group.userData.pathToNode
const isFullyConstrained = isSegmentFullyConstrained(
pathToNode,
ast,
code
)
const color = getSegmentColor({
theme: sceneInfra._theme,
isSelected: group.userData.isSelected,
isFullyConstrained,
})
// Update the material color
const arcSegmentBody = group.children.find(
(child) => child.userData.type === ARC_SEGMENT_BODY
) as Mesh
if (
arcSegmentBody &&
arcSegmentBody.material instanceof MeshBasicMaterial
) {
arcSegmentBody.material.color.set(color)
}
}
// Calculate start and end angles // Calculate start and end angles
const startAngle = Math.atan2(from[1] - center[1], from[0] - center[0]) const startAngle = Math.atan2(from[1] - center[1], from[0] - center[0])
const endAngle = Math.atan2(to[1] - center[1], to[0] - center[0]) const endAngle = Math.atan2(to[1] - center[1], to[0] - center[0])
@ -1403,6 +1675,8 @@ class ThreePointArcSegment implements SegmentUtils {
isSelected = false, isSelected = false,
sceneInfra, sceneInfra,
prevSegment, prevSegment,
ast,
code,
}) => { }) => {
if (input.type !== 'circle-three-point-segment') { if (input.type !== 'circle-three-point-segment') {
return new Error('Invalid segment type') return new Error('Invalid segment type')
@ -1417,8 +1691,19 @@ class ThreePointArcSegment implements SegmentUtils {
p3[1] p3[1]
) )
const center: [number, number] = [center_x, center_y] const center: [number, number] = [center_x, center_y]
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor // Check if segment is fully constrained
const isFullyConstrained =
ast && code ? isSegmentFullyConstrained(pathToNode, ast, code) : false
const color = getSegmentColor({
theme,
isSelected: !!isSelected,
isFullyConstrained,
})
const baseColor = !isFullyConstrained
? getPrimaryColorForThreeJs()
: getThemeColorForThreeJs(theme)
// Calculate start and end angles // Calculate start and end angles
const startAngle = Math.atan2(p1[1] - center[1], p1[0] - center[0]) const startAngle = Math.atan2(p1[1] - center[1], p1[0] - center[0])
@ -1500,6 +1785,8 @@ class ThreePointArcSegment implements SegmentUtils {
group, group,
scale = 1, scale = 1,
sceneInfra, sceneInfra,
ast,
code,
}) => { }) => {
if (input.type !== 'circle-three-point-segment') { if (input.type !== 'circle-three-point-segment') {
return new Error('Invalid segment type') return new Error('Invalid segment type')
@ -1523,6 +1810,32 @@ class ThreePointArcSegment implements SegmentUtils {
group.userData.radius = radius group.userData.radius = radius
group.userData.prevSegment = prevSegment group.userData.prevSegment = prevSegment
// Check if segment is fully constrained and update color if needed
if (ast && code) {
const pathToNode = group.userData.pathToNode
const isFullyConstrained = isSegmentFullyConstrained(
pathToNode,
ast,
code
)
const color = getSegmentColor({
theme: sceneInfra._theme,
isSelected: group.userData.isSelected,
isFullyConstrained,
})
// Update the material color
const arcSegmentBody = group.children.find(
(child) => child.userData.type === THREE_POINT_ARC_SEGMENT_BODY
) as Mesh
if (
arcSegmentBody &&
arcSegmentBody.material instanceof MeshBasicMaterial
) {
arcSegmentBody.material.color.set(color)
}
}
// Calculate start and end angles // Calculate start and end angles
const startAngle = Math.atan2(p1[1] - center[1], p1[0] - center[0]) const startAngle = Math.atan2(p1[1] - center[1], p1[0] - center[0])
const endAngle = Math.atan2(p3[1] - center[1], p3[0] - center[0]) const endAngle = Math.atan2(p3[1] - center[1], p3[0] - center[0])
@ -1619,6 +1932,8 @@ export function createProfileStartHandle({
theme, theme,
isSelected, isSelected,
size = 12, size = 12,
ast,
code,
...rest ...rest
}: { }: {
from: Coords2d from: Coords2d
@ -1626,15 +1941,30 @@ export function createProfileStartHandle({
theme: Themes theme: Themes
isSelected?: boolean isSelected?: boolean
size?: number size?: number
ast?: Program
code?: string
} & ( } & (
| { isDraft: true } | { isDraft: true }
| { isDraft: false; id: string; pathToNode: PathToNode } | { isDraft: false; id: string; pathToNode: PathToNode }
)) { )) {
const group = new Group() const group = new Group()
// Check if profile start is fully constrained (only if we have AST, code, and it's not a draft)
const isFullyConstrained =
!isDraft && ast && code && 'pathToNode' in rest
? isSegmentFullyConstrained(rest.pathToNode, ast, code)
: false
const geometry = new BoxGeometry(size, size, size) // in pixels scaled later const geometry = new BoxGeometry(size, size, size) // in pixels scaled later
const baseColor = getThemeColorForThreeJs(theme) const color = getSegmentColor({
const color = isSelected ? 0x0000ff : baseColor theme,
isSelected: !!isSelected,
callExpName: 'profileStart',
isFullyConstrained,
})
const baseColor = !isFullyConstrained
? getPrimaryColorForThreeJs()
: getThemeColorForThreeJs(theme)
const body = new MeshBasicMaterial({ color }) const body = new MeshBasicMaterial({ color })
const mesh = new Mesh(geometry, body) const mesh = new Mesh(geometry, body)
@ -1645,6 +1975,7 @@ export function createProfileStartHandle({
from, from,
isSelected, isSelected,
baseColor, baseColor,
isFullyConstrained,
...rest, ...rest,
} }
group.name = isDraft ? DRAFT_POINT : PROFILE_START group.name = isDraft ? DRAFT_POINT : PROFILE_START

View File

@ -1,4 +1,5 @@
import type { AppTheme } from '@rust/kcl-lib/bindings/AppTheme' import type { AppTheme } from '@rust/kcl-lib/bindings/AppTheme'
import { converter } from 'culori'
/** A media query matcher for dark mode */ /** A media query matcher for dark mode */
export const darkModeMatcher = export const darkModeMatcher =
@ -58,6 +59,84 @@ export function getOppositeTheme(theme: Themes) {
return resolvedTheme === Themes.Dark ? Themes.Light : Themes.Dark return resolvedTheme === Themes.Dark ? Themes.Light : Themes.Dark
} }
/**
* Converts OKLCH values to RGB using Culori library
* @param l - Lightness (0-1)
* @param c - Chroma (0-1)
* @param h - Hue (0-360 degrees)
* @returns RGB values as [r, g, b] where each component is 0-255
*/
function oklchToRgb(l: number, c: number, h: number): [number, number, number] {
// Create a converter from OKLCH to RGB using Culori
const toRgb = converter('rgb')
// Convert OKLCH to RGB using Culori
const rgb = toRgb({ mode: 'oklch', l, c, h })
if (!rgb) {
// Fallback if conversion fails
return [255, 255, 255]
}
// Clamp values. When OKLCH values represent colors outside the sRGB gamut, the RGB values can be negative or greater than 1.
const clampedR = Math.max(0, Math.min(1, rgb.r))
const clampedG = Math.max(0, Math.min(1, rgb.g))
const clampedB = Math.max(0, Math.min(1, rgb.b))
// Convert from 0-1 range to 0-255 range
return [
Math.round(clampedR * 255),
Math.round(clampedG * 255),
Math.round(clampedB * 255),
]
}
/**
* Gets the primary color from CSS custom properties and converts it to Three.js hex format
* @returns Primary color as a hex number for Three.js, or fallback purple if unable to get CSS value
*/
export function getPrimaryColorForThreeJs(): number {
if (typeof globalThis.window === 'undefined' || !globalThis.document) {
// Fallback for SSR or when DOM is not available
return 0x7c3aed // Default purple
}
try {
const computedStyle = getComputedStyle(document.documentElement)
// Get the individual primary color components
const hue = parseFloat(
computedStyle.getPropertyValue('--primary-hue').trim()
)
const chroma = parseFloat(
computedStyle.getPropertyValue('--primary-chroma').trim()
)
const lightness =
parseFloat(
computedStyle
.getPropertyValue('--primary-lightness')
.replace('%', '')
.trim()
) / 100
if (Number.isNaN(hue) || Number.isNaN(chroma) || Number.isNaN(lightness)) {
console.warn(
'Unable to parse primary color components from CSS, using fallback'
)
return 0x7c3aed // Default purple
}
// Convert OKLCH to RGB
const [r, g, b] = oklchToRgb(lightness, chroma, hue)
// Convert RGB to hex
return (r << 16) | (g << 8) | b
} catch (error) {
console.warn('Error getting primary color from CSS:', error)
return 0x7c3aed // Default purple fallback
}
}
/** /**
* The engine takes RGBA values from 0-1 * The engine takes RGBA values from 0-1
* So we convert from the conventional 0-255 found in Figma * So we convert from the conventional 0-255 found in Figma